找回密码
 注册
查看: 5719|回复: 0

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

[复制链接]
发表于 2006-12-9 21:57:50 | 显示全部楼层 |阅读模式
    我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。
" Z6 d; w2 ^; w/ a5 o
; I" T% ~* B, U- f) u4 u; l在这篇日志里面,你可以获得这些信息:
3 Y3 x; J! w( E3 j- g# X9 H( V
: L4 f+ Y4 m$ L/ `1 人物动画的框架
; }5 k9 x% C( ^2 骨骼动画及蒙皮技术
% x# I; t% v4 J- ^- d# z. a3 doom 3和quake 4中模型和动画格式md5及原理1 q+ s7 w! G5 j' q6 S0 L+ E
4 可能的扩展+ ]6 [- W1 A5 s- _$ x. a

7 Y0 p- i5 e* S3 `9 \; k: R* i& k8 W; D3 |' j: |9 P
先来看一下人物动画的几种方法:. \4 `3 V" Y, b+ Q

9 T+ Q, C% u! G7 l3 [一、简单关键祯的动画
- m  w& W+ q- \- J" t8 i+ N- m8 z" a2 {# K" N; @+ J
  像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。
- y5 D. Q" K) j: u1 l3 ]# G0 Y2 Q; J7 d+ e
二、简单的骨骼动画及蒙皮技术
* L" w6 y/ P, B# p4 @& A% D6 U4 A0 R
  现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。
* Q" E* r0 @/ o! [! ~% r" \- M
" v- g5 a/ ?; y; v4 w. |三、改进的蒙皮方法和基于物理的骨骼动画
, m4 w. A9 \( Q
1 [5 `" C8 X  b) X1 u  m& t- j  改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;
2 z( ]2 X5 x9 W& m& {0 @/ u
: Z2 o2 g% q  {$ a  基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
9 ?2 M. k: o+ o' C' G
/ t, U0 j; g) S7 O2 X& E- L
& i2 X8 p- r- d. H
! I* r; V5 H5 M$ e5 ]基本的蒙皮原理
4 I" S5 \: B- ^) E5 P& i; Q! m% o9 R  W& I. }
拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:" T* K* U% s) q1 b1 y( f  c! F- r
" I, ~+ V( [/ v
Joint: 用来记录骨骼的关节的信息;
  r4 i4 [: f3 `1 t( F! G; yWeight: 用来记录顶点相对于关节的权值;; Z& ?7 E9 G; ^; f5 N! r# s- {& y
Vertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;( S' Y' ], u: o# P0 n
, _' {; s* _- u. f5 ~
现在就来解释一下这三者之间的关系:
( H0 Z" z. t6 o( h$ o4 ]4 C, [+ r
  Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。. z5 u! w0 i" Y1 V- d
( y) g% R2 I3 B; }& Z9 V
  很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。& A: K+ |7 z, y4 m8 D; W

5 l( a8 N# V6 Y9 _& o, h' R  了解这些基本的概念,下面就来介绍人物动画系统的框架。8 a, l% p+ \/ t9 Y1 i
. T( e/ M- ?2 @2 ^  `8 @3 o
; V% s; `) O. {; T
骨骼蒙皮基本框架
) O# Z1 `( [% J' ~4 s6 B
* @6 V9 o. }% g: Z% C9 A! c" j基本的类型:
: k$ L7 p- m# p, R# D' R% z- {* ?. R7 B' x- z
关节信息:
* [% X% L4 {! n' K" |4 S) F' ^0 X
typedef struct _CharJoint
% @/ |8 w+ t0 r4 p: m6 C{2 m1 \9 f9 Z# w: R
   Vector3 pos;
7 ?/ c3 @; i' a  V2 A    Vector4 startPoint;' |) Q, c- |" r* G. M: B( a
   int parentID;' N) N6 i7 ]% `* F$ t
   char name[32];
  S, l# O! A0 r* ?/ E} CharJoint;
* [: y2 ~/ l2 U  a7 u  B( R* c) R% |! C! B
其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;9 a: C( V$ N7 @: l! s/ O
  x) T- F+ z: `* f
权值信息:6 W  U3 ?  O8 G( D) U6 R! p, [4 {

; F' J8 l7 B' P7 U! ntypedef struct _CharWeight
9 n+ h( T* D( w3 A- W  p{
0 o" F; [* m4 p& L+ m    Vector3 pos;7 [0 ]* v  ^4 H# H; Q9 F
   int jointID;
  F$ k& }! K7 V. M" w    float bias;
* V6 c. {* G7 M  m* ^# M} CharWeight;
( l1 M$ C& T- d8 k; t
, O4 ~8 J" u2 Z6 J# j其中,pos为偏移量,jiontID为对应的joint,bias偏向值;
! D* N9 `& R% c
% }9 @, @7 k, i, H) ~5 M
% ?. N4 Y' A7 ?- b顶点信息:% G5 A: R" ^* p. U
  E' D0 D" k+ d  M! m6 @3 U7 Y
typedef struct _CharVert' r6 u- d& A9 K) d9 o7 O6 B
{
8 b: w3 a' v6 a6 }    float u, v;
  H6 k0 X- o3 P- r' P9 d    int startWeight;
6 L1 v! B6 y/ Y* [# [    int weightCount;: I* L/ a6 [4 _+ x$ o" x
} CharVert;+ @7 i; F: K3 ?9 {) z0 [

3 ]+ F* R+ X7 t9 A& U  其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。9 j( i7 @! e" P
; m8 l2 e+ L% R

& P4 Q1 K4 @/ z/ k大概还涉及到这样一些类:
! f, I) ?. G. e) m
& q! a9 k0 Q/ f  U* CCharSkeleton: 记录整个骨骼的信息,包含了关节的链表;! Y- Y# m1 |' q0 m/ S
CharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;
- a  J+ B) S' H2 T9 uCharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);
- s1 v& j4 v8 C9 o0 e9 H/ BCharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;
2 D. G  p' g# A4 oCharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);& k( u  B& R' C9 U4 Z  o
# K4 o% A/ R- T, Q$ u$ V1 X
1 w6 N& ^# D: E0 ]7 ^, Y8 F
解决关键问题
9 B* ~4 m1 i7 o: p! W. U0 u8 i- y' S3 s- E$ T* {; I6 Y
  刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。
) |! q" F! c/ U" F( o
! E* a' A* r6 l$ m$ V关键祯混合:
3 K, N. f, e$ ]/ p$ p
9 y8 M! Z+ m+ o; c! F+ 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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);
$ g% G. O1 y3 X# H
( @- j" D3 b& g: y6 L  际应用中还有其他的混合形式,后面再来介绍。
' z7 o* S9 t7 Y$ @+ E8 G0 t3 G: q% N8 b: v' g1 w) l
% A. s, y5 i( A+ F
计算实际顶点:9 b5 K& Q; w0 o' u2 V
( Q% |" I# k1 Q; j" Z
我们看一下软件的(用CPU做蒙皮)Blend过程:
4 \2 s) q$ A2 L1 y% l( c) c( b: a+ q5 q, v: T! w/ U
void CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )
, a" [  ]  R" k  e! y) n{
) [8 X4 l8 `& o7 w    if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )
9 T" e( r% Y, y        return;
6 l$ a4 d* o& j$ u0 |) n/ Z" \# F! b# E8 B
   CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();% U: r' z# a5 L( [* O
   int numVerts = inputMesh.GetNumVerts();# V0 o- [* x$ H, T1 ?1 W
   int numTris = inputMesh.GetNumTris();
4 W/ K1 E3 y- Z- L7 a, W, F6 d( u) h+ x* b1 v5 |
   for ( int i = 0; i < numVerts; i++ )
+ J7 y, M; a: y8 m' I    {1 _( G- }8 p7 y0 N
       const CharVert *pVert = inputMesh.GetVertAt( i );
+ X4 I2 T' \% G+ R; m: `        pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;
' u6 A% ~! r5 Z/ H8 L- a7 l! {# c: W
       /* u v initial */
+ J/ D+ l. c: j        pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;; m0 e4 ^2 ^6 m/ e$ c$ i
       pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;( x& L% n7 C0 I% ?8 r+ a
( Q( A# c  L" n( M/ [4 [( V, I3 X
       for ( int j = 0; j < pVert->weightCount; j++ )" I! @1 E' B% _1 e4 L5 v
       {8 y7 n0 @! J; O& W; C; h- R  ?
           const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );; t* A% o' r% Z+ O6 i* q% y
           int index = pWeight->jointID;
) [3 `% i  L  `. F            const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );+ w: F6 N) F+ u( z
           vec3_t wv;; `7 \5 z7 q% U6 s
           Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );
2 {5 ~" {/ m& |' r0 N8 S            pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;
; u, V/ l1 M/ t            pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;
6 y4 {. R; c# r. _1 `            pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;: E; q6 Q" L* o( p* w/ G5 p
       }; `, Q$ y& z2 ~1 M
   }
, L7 {" p8 V2 K4 b5 P8 J0 N* C- l7 J: h2 o: N/ v
   outputMesh.UnlockVertexBuffer();
. [2 z/ C: N& m$ U# g: q0 I# t+ X) j% L. @
   CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();
. ~% J5 z0 T3 f) X" v6 a6 p0 O
1 V1 G8 l; U4 ^, s2 r) e; D9 y    for ( int i = 0; i < numTris; i++ )  O- C" U( {0 L) s, r' N) U
   {
+ n" y% z0 g. _. s, U        const CharTri *pTri = inputMesh.GetTriAt( i );5 H; \7 [% h5 N9 |* u, h( e' v# R" l
       pOutTri->index[0] = pTri->index[0];! I, z2 A$ e& X8 K/ n6 e
       pOutTri->index[1] = pTri->index[1];% i: j- W4 d4 a" i/ \# S$ D
       pOutTri->index[2] = pTri->index[2];2 M3 X( S; G7 \, f1 g+ N
   }: p# v! n0 N2 g4 y3 r! @  E

$ [* I* F. I$ [: \4 b    outputMesh.UnlockIndexBuffer();) A2 B! ^% K" b# R
}
6 ?* q3 H7 q4 [  w1 f6 G) g# E  M9 T! K6 T& S
其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。
6 c( Z& m3 T3 s3 \- N7 G6 f& @2 m6 p6 {
3 B% K% X% U) J- @; _5 {; ^
关于md5anim文件8 J! n9 b. u: r% ]4 F  @
! ?% p- \4 v7 N8 m0 e
  Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。
/ ?: b/ R6 t4 T; [/ U& h. v+ q7 R8 `9 B5 U, V

) X" u' v7 _- a9 J* V$ f可能的扩展
  R. t0 @6 m7 z! k0 l- G, y/ G9 }. W3 |
一、复杂动作的混合* d2 h. \2 P. O, g+ I
/ F8 n( w/ W3 H9 n/ P# j# d
  有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。
+ O+ e$ F3 v4 E
4 Z- c  \' N7 }* v" e) ~" X二、基于物理的动画4 J# F) h  y! S- p! g2 F3 Y& ~
! ]4 O% V4 Q- u
  这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;
# O0 N' e* i: m  q  E2 q% N: W) K1 G, W
三、基于GPU的蒙皮
8 J8 {8 h% J2 X
8 _# ]% m3 {5 V2 I: C  原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。+ Z- S; [) r6 h3 |! D" j2 v
% v1 o6 t' V/ n2 q* A
四、非常流行的“换装”系统
/ b* r& R- i8 \" i& B7 N" J
) P( _( X4 `, a4 x3 P  这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。
您需要登录后才可以回帖 登录 | 注册

本版积分规则

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

GMT+8, 2026-10-2 02:30 , Processed in 0.017193 second(s), 15 queries .

Powered by Discuz! X3.5

© 2001-2026 Discuz! Team.

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