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

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

[复制链接]
发表于 2006-12-9 21:57:50 | 显示全部楼层 |阅读模式
    我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。* O5 M. l9 u& X

# W* [$ h1 J9 P2 A1 r9 W在这篇日志里面,你可以获得这些信息:' g* y3 P5 K+ R$ x' _6 I
/ C, k( x1 U2 l$ z$ v7 y
1 人物动画的框架
# ^/ ?8 L& z; H% R2 骨骼动画及蒙皮技术
6 B; Z7 d. z, {3 doom 3和quake 4中模型和动画格式md5及原理- Y! T2 V1 B. t0 i7 Z3 ?
4 可能的扩展
0 S9 b- J+ K$ h; Q
" }2 t4 M2 g; l7 p" v) a4 m# f1 c5 E6 g* @8 P
先来看一下人物动画的几种方法:; h5 Z# ^; _2 O! s# U3 z5 l! w

. m) p" j" x+ v2 u( p: W一、简单关键祯的动画
! h) ]) d' ~6 q( s$ s1 K" p3 x. z8 t9 s1 t- ?* {1 b$ I# v2 B
  像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。
% l$ H4 E6 k! L0 x. N! p
2 w# J' F8 K, ^) |/ X% z* R二、简单的骨骼动画及蒙皮技术% y% j, r9 i0 }# b# Z% w7 Z% ^" p
/ G: i" J' ^; u. Y
  现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。
1 `! ]0 g' n- d5 c, B+ t& T$ J, Y, W& b- |# X* B) t
三、改进的蒙皮方法和基于物理的骨骼动画
2 S( ~8 M: f1 o8 m4 u, v$ C7 l# o- D$ i
  改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;
) I2 \7 q) `' R+ h9 B, {3 ]2 n, A; f1 X0 g  ~, M) i7 v
  基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。, n9 K' Z3 |- H, X$ W6 K

3 W$ M' ?& f, z  s1 @+ u3 L: F. h$ N
' v1 Z; d, m* B4 \
基本的蒙皮原理
8 }& O- K9 _' x" ?+ t" G/ z' g$ }$ w+ ]- o" z8 p
拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:( M  H) K5 b+ x

: h1 [- h+ [/ ~1 q4 T4 iJoint: 用来记录骨骼的关节的信息;7 W+ ?( k# P7 p* t) G. Y
Weight: 用来记录顶点相对于关节的权值;; n2 R' H" t  [1 K) q( z+ t
Vertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;
# y: z* C' [5 X+ G' v: \- n9 ~, E
) {7 a. `1 z$ a$ L7 I+ z3 s) o& ]现在就来解释一下这三者之间的关系:  a3 B3 [. ~% }. f
* n$ ~  }* A) `  |! f: `
  Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。2 e1 v: k! B% S, ]1 m+ D
8 f. N% [1 X3 Y3 i. }# T
  很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。; l9 y1 J, O" Q# b* |

( L! ]# S3 V* O! y  了解这些基本的概念,下面就来介绍人物动画系统的框架。" R& {4 p' d' ^% Z9 i' V. F

; x  _/ f* i; Q2 f! i' t( T
8 q5 B( e9 o' `6 S# o3 F骨骼蒙皮基本框架
4 Q* f9 U: J4 K
7 Q3 v; e6 l) \0 H, ?基本的类型:) R5 _# w( F) ^/ a7 m5 ?- G
, \: }2 W7 _4 W9 c6 B
关节信息:" k: |' }; [4 T
2 L  K7 Z# C9 ]
typedef struct _CharJoint% U2 ]) T9 Y0 }* {3 c( d
{. R4 B0 [7 `5 Z! F' y
   Vector3 pos;4 k% h) e5 i" i# ?: E+ O
   Vector4 startPoint;: y  i$ _0 X9 q2 G& M
   int parentID;; v* `) I8 D8 B. \7 m( K
   char name[32];
/ j1 j  O1 V. I# Y9 E4 M} CharJoint;, [- m; a* h2 q& I' r/ U

% v& q" J- Q& @% m3 v其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;# a: e& X# S+ U6 p  ~  {8 M
7 \7 }: K; u$ l3 X) T1 T
权值信息:
' y5 x' G; [" R3 ?# R
; B. B9 e# ^2 {! b) I; ttypedef struct _CharWeight2 c  z( d( m1 W9 A
{
! _" g, _+ J: A' Q    Vector3 pos;
; a1 q4 P9 c' G. r1 ~0 [6 t    int jointID;
7 a2 n" \! G% b9 Z& m- ?5 F/ l    float bias;& D* _0 w6 I) R/ {3 t, J
} CharWeight;
0 E" g& u) ?! I3 m4 m, h. B7 H6 r" o% V6 C3 v: |, R6 a
其中,pos为偏移量,jiontID为对应的joint,bias偏向值;
7 S- C9 }% G3 Y% k& r  W2 W1 o' X
) N3 _0 k! ^# e6 ]& k/ C, E9 @7 c! u, M4 `) ^: g. Z' R
顶点信息:' F; |8 z. B, b/ O; b- }9 W0 M& `% O& f
; w' l! V  v/ O  }/ u
typedef struct _CharVert- ?7 y3 F% `; e+ f6 t' i, X% u9 m
{
: S+ Q# }' B$ c% [  [    float u, v;. ~3 ]  V* q8 G0 k1 |$ ^
   int startWeight;
* [3 [' }. M' x6 ^7 j- v    int weightCount;; O  G- f# j3 r
} CharVert;
- V3 ?! I$ i' k6 j  e) }# Y8 Z* t9 D: z3 Z
  其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。) U' e* ?7 n2 e

( S; h5 Z+ J2 ~- ?1 @- x. G6 S8 f. F% U( P
大概还涉及到这样一些类:
- T0 [$ ~$ `) q" q* w
- T" a2 Y+ X0 D: U( [3 ^4 y8 ACharSkeleton: 记录整个骨骼的信息,包含了关节的链表;
9 D) \% ^; t$ ~0 }CharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;
3 m" V6 b$ A/ C$ ~6 o. r2 UCharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);
) j, W) a" P, H# a0 O/ tCharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;
8 ]% R  D& N4 |  GCharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);: v1 c8 J( G$ r# E& [- S/ p4 ~
* _+ m' ?2 M; K( h/ X% z4 h- ~2 A- L
! O7 I5 _: K6 l
解决关键问题
& d; n8 \6 r# S: U- u$ i  F* a, F
4 ~/ u5 r$ S& r% }% D0 _& w! u- _  刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。
# g$ D  R4 ^! T" x
( V3 z1 _/ j1 Q& i9 I: q0 h. i关键祯混合:4 b) @# L+ f5 x1 J
; `6 V$ r& Y2 x% c! w1 ?5 V
  简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);' Y# X6 l% J2 d* P2 {

: ?5 G# q  `+ m( E% R9 W& J& C! M  际应用中还有其他的混合形式,后面再来介绍。
  o2 d$ k3 d& c3 t: A' ]" }
" c) t4 m( V0 \; I9 p' M# M
" S3 H3 X2 t/ W4 B  C计算实际顶点:. F' E0 j) w) {. V
* H4 ~+ I3 J/ A
我们看一下软件的(用CPU做蒙皮)Blend过程:
0 i) \8 o3 _7 K8 |7 \* t, _2 s  y; w' e2 H, D
void CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk ); S' g: R' c/ u0 G: K  u6 q4 }
{# A. t3 d$ [  i
   if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )
( ^4 m$ Y1 s9 q( }( a        return;
0 p3 |$ ~0 o4 s$ O$ d
2 K, f* {' m% o# Q& {    CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();8 D6 c; c# c* \! w  Q" T
   int numVerts = inputMesh.GetNumVerts();. S# m: L4 e. u: _& D: _
   int numTris = inputMesh.GetNumTris();! ]$ s6 p7 S0 Q; o
& ]+ C; }% q( U8 a) z- `4 ^
   for ( int i = 0; i < numVerts; i++ )
% q: B' S/ j) n" v5 H    {
: ?0 j& T1 I' r        const CharVert *pVert = inputMesh.GetVertAt( i );" e' o2 y: J5 ^, ^( u2 S5 O
       pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;. `6 z. V+ `' ?" W

/ `7 {! M0 H1 A' T3 C5 c        /* u v initial */
6 K1 U  @" L9 k2 D        pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;1 F7 K5 |8 k. M! [! N/ x' X
       pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;
9 V7 p. r9 s6 ?, b+ f8 p- U/ t# h3 V' y
       for ( int j = 0; j < pVert->weightCount; j++ )
* b9 q8 Y  m+ s& W; U4 D8 x1 F: b        {& Z' V$ r  ?( `+ ^2 w4 N6 L+ h; u
           const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );  L- y( K7 G% @: Q5 u: I$ C
           int index = pWeight->jointID;* H+ Z  S# q6 `) n% L7 A
           const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );
$ W: q4 ~8 j( _  e9 j            vec3_t wv;% ?0 Y$ [) C7 I# d0 F6 E' q, x
           Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );
/ a2 c! b* f9 k5 G8 v, W4 v$ I0 R            pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;
. r  w8 O7 u1 R4 f6 Q            pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;- ^/ a- X5 j+ |* {& t! W" Z- W
           pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;6 O( w* q! k# U# h$ G
       }7 ~# j  C; V9 ]% E( s
   }
' V6 }% W- ?) S8 q7 P/ |
: _4 ], }& p& B2 n    outputMesh.UnlockVertexBuffer();
7 n' M8 u4 Z4 q3 g  P7 ]3 E! f' k& _* m/ x
   CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();
, u2 I( `4 X$ o+ Y1 Q. J* Q. u9 b7 d3 }) R
   for ( int i = 0; i < numTris; i++ )
( A7 Q4 K5 T4 y8 G9 H, \. u5 s  A    {7 i% s) @- q9 S0 x- j$ R7 l2 \
       const CharTri *pTri = inputMesh.GetTriAt( i );
) L+ n. I- ]& \) E5 @8 y        pOutTri->index[0] = pTri->index[0];
! `7 ]) J0 y: P4 I        pOutTri->index[1] = pTri->index[1];# @/ r: A9 m4 b
       pOutTri->index[2] = pTri->index[2];3 V. [) Q$ i: e4 _0 T" }
   }
' C0 c0 x& V$ u0 O! s9 H( j0 g* s$ Y6 Z# L5 F
   outputMesh.UnlockIndexBuffer();2 j: B, r, ~+ y5 A/ m  H
}- H" W4 a5 [, t

8 Q) z# ^# z. J& S( z5 N' U3 W其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。
7 |' B* F' n9 n- a0 l5 B0 ]8 y- y3 r$ T. P7 ?' Y. J* W1 O2 B
5 p) b. b" c* S) g" C7 S) O/ c
关于md5anim文件
7 h% r2 o# w2 u9 J% e9 D! _; d1 v7 k2 @* ^- @/ H7 f% b
  Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。
+ q! C  |- p8 I. [* O+ v, M
0 b( K6 \0 K/ ?2 P
8 R9 N4 {- {$ b& q# |可能的扩展
* u* L. E- @0 O7 d) Z: r2 s8 W' T$ t- f4 |0 }
一、复杂动作的混合' s, n8 R. b# M  d1 ?! \

' W6 N! @  C7 X0 U& o  有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。
# H) J" _% j8 |+ k; F
! F; t0 H2 x1 {6 |二、基于物理的动画
0 {) I0 K0 K4 ~8 E9 M+ Y8 U# K
; o0 O; z% i: D  r- y. l1 h  这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;4 q6 ?  Z2 P7 C  ]  G8 B$ z
' F1 z- D/ M0 s; M
三、基于GPU的蒙皮& E/ C: N- s+ P, b
0 |3 X% i8 ~6 }6 i0 b+ }7 N- b
  原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。! k: b8 V1 A9 ~4 i: {- U8 O

4 m( w4 D# Q$ _+ B8 \) D$ m/ G) L% w四、非常流行的“换装”系统
6 I' p3 P( \" r# P% D( J: ^* ^
8 }$ e: w" _, U  这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。
您需要登录后才可以回帖 登录 | 注册

本版积分规则

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

GMT+8, 2025-6-20 00:28 , Processed in 0.035038 second(s), 15 queries .

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

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