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

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

[复制链接]
发表于 2006-12-9 21:57:50 | 显示全部楼层 |阅读模式
    我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。
  K/ {6 {5 o& X) T6 U. W
' V, H8 r8 \2 I$ B. V- p在这篇日志里面,你可以获得这些信息:
1 C4 R, P7 C6 G9 |5 x7 Y: N" z3 b0 i) s$ X
1 人物动画的框架9 |6 }0 \; c  R; `  x
2 骨骼动画及蒙皮技术
& a6 T4 h: |  X# W$ A" u$ V, i: Q3 doom 3和quake 4中模型和动画格式md5及原理# ?6 a: \: |! n' e0 Q2 L( O' r' G/ k3 }
4 可能的扩展
% l/ v, }- q( i# l5 O1 G2 r' q/ ~! B! C# K, x. y
0 a, C+ P4 |2 f- J* G5 W' |! L
先来看一下人物动画的几种方法:
) s$ i9 ]& D5 e, t  u; g. a/ s7 A5 S) V: e
一、简单关键祯的动画
* ]6 s6 W1 ?9 Y2 A; m2 [; j& E% h5 V
7 e' x$ M" O2 g; K  y1 `$ B) k  像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。, n& E. z  M6 S& X+ I: P2 n
  d  n! ?5 O- e8 a2 V
二、简单的骨骼动画及蒙皮技术
, N9 u* O% d5 q! O9 \
6 i) e  v0 H5 z& [: Y4 h  j7 U  现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。/ h- d* t. }; C: o) p
, d/ e6 z1 |* N+ z+ s* z8 `1 O! U
三、改进的蒙皮方法和基于物理的骨骼动画
1 I7 b" i( W3 ~4 c% ?4 m5 K' c& Y3 F
5 d) p" d2 n; z$ \/ `& d  改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;
5 q. M9 C' M/ U" \: Y) W3 y5 m6 j1 Z: ?7 H
  基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
, H" Z8 q4 Z! S. s' B: k3 |( j
/ A. e' V0 V  }& [* [8 ?" Z% C: D# p, s7 _; r& M/ U

5 S4 O8 y& s6 o6 K' g基本的蒙皮原理$ Y! s+ R! Q6 h

+ j! R7 a$ ~3 L2 E- P/ L4 `6 c拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:% a) w# e7 B% A0 c
7 T/ Z+ w) k, g, N$ H" c
Joint: 用来记录骨骼的关节的信息;8 ?' P# r  N" B$ A' @) k9 q; X
Weight: 用来记录顶点相对于关节的权值;
! X$ J6 h( M2 t2 L7 J9 t$ Z4 zVertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;& }: Z4 k) A$ M0 I  y

2 F7 s8 Q/ s( S* x) p  p现在就来解释一下这三者之间的关系:
0 b6 c8 \: H" `/ j' K9 W5 A% _5 m2 I5 U
  Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。% h. @0 [7 q  h( ?0 q8 B
8 j  w. f0 x9 E3 ^( R& z& r; ?5 I
  很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。
( E  z3 o- ^6 c
9 z& h" o) d: c( ^. `  了解这些基本的概念,下面就来介绍人物动画系统的框架。
3 r  b8 Q+ U* X& j7 `9 m
$ F$ p* N  |" v) Z6 H7 m4 b: E9 q! n& Q' n
骨骼蒙皮基本框架
( c( M4 c4 L5 r) i4 Q0 J
$ H8 \: l3 P2 l9 s/ I基本的类型:: \* w$ |4 \! _4 p
# k4 K; n5 F/ k3 z* p- r
关节信息:
6 ^9 A% J! P. Q6 P2 M* B* T* g6 E* V" o& K$ L
typedef struct _CharJoint& c) f* U/ O! l. U& e/ x
{# x: G, c' n" \  f; W: i
   Vector3 pos;
, ~7 Y/ O1 `1 ~* A' }0 v6 [    Vector4 startPoint;
/ N) c6 a' U5 Q( Q6 Q    int parentID;- z. S/ r, V& `6 ~: E
   char name[32];! T! B; ~2 j* y1 `3 V# h+ I
} CharJoint;8 H4 {+ L' R" n

; w# N. o' k$ `! Q7 k# R8 |8 q% n其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;; @) D; C6 O3 F& u' x

4 d, i% V" |# S) O' }权值信息:* P+ D* d. }$ M5 ^9 n9 q' I) ?/ n

  ^# C, |% E) A, Vtypedef struct _CharWeight
2 ^* a, \9 j4 \6 ^% H/ d1 |- z{& A# u5 P% w+ ?
   Vector3 pos;) }, d( a+ ?5 X) I' _
   int jointID;
7 o& k* L$ Q, {" c3 p    float bias;$ w3 _6 z: t" b, F: L6 ~% r
} CharWeight;1 @8 j  U8 B& `( ~+ Z' f2 o
9 e3 V# c8 h- J- ^/ L
其中,pos为偏移量,jiontID为对应的joint,bias偏向值;$ w9 o& c. v* N2 J$ {# a  \
9 r. O0 D8 o) F* I% x( S0 b- l6 g9 w

& U& k0 M: |0 u8 R" r5 s  N顶点信息:
* Y: m' Q4 J8 \- u! A5 m& i) ^! C$ _% d+ _1 N
typedef struct _CharVert+ p' Z+ x6 \6 L6 k
{
) p: M8 h3 y+ M5 a    float u, v;' t& F7 ~; b  l7 B+ Y
   int startWeight;" y# m# s0 P3 D! Z: a
   int weightCount;* G6 D9 w5 |# K4 H$ Y* P
} CharVert;
5 p2 r$ v9 E% E& l% s  H6 g3 T! x  V+ T$ @6 [/ a% c
  其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。* Y' h; t& B; [* ^
+ w! I4 R" d0 V
$ w2 c9 m- M0 R" \
大概还涉及到这样一些类:4 ?6 c7 g9 N3 \6 u$ g
- M6 B6 i) ^2 A+ c: M
CharSkeleton: 记录整个骨骼的信息,包含了关节的链表;
- ?2 r6 y2 m2 G1 H: J+ v, i1 a8 @CharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;4 b- |  j. y1 o: }7 C6 ~% Z
CharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);
# @9 c/ e+ G' @3 C5 bCharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;$ B  n' m- G, h6 o" I5 T/ z
CharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);, |) o5 A2 E  F4 X+ V6 I/ D
9 l6 _% \% N: S) Q; n$ D9 b% M

" e; J5 O$ Q/ l, ^' _$ T解决关键问题0 |2 l. R( T: w; H
: c( {; p3 i2 D8 y) T! h( [
  刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。3 s6 Z" x# P" [. _

, q$ U2 I' C6 a# ?9 p关键祯混合:
6 P  D$ j7 j- X6 V
- e- L3 t: X, O0 \! r  简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);
/ f% }2 U. W' t6 z- Y, K7 J- z1 c) v/ O1 u/ L7 V  m! @. I
  际应用中还有其他的混合形式,后面再来介绍。
2 K; I  Q+ P% S5 o: `6 \1 M7 M' ~2 _8 `. x* @6 T$ Z- s' [2 N5 R0 ^" `
, ]: w  e% B  Q4 ]
计算实际顶点:
- j( F$ o& K1 M
2 D( S& V- S' m9 ~, Q: R$ p" t2 K  T我们看一下软件的(用CPU做蒙皮)Blend过程:
' \0 X# l7 x3 [7 O8 N9 h4 x0 J; l, M5 V& S# N( t  m
void CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )/ j; y+ y5 E! h# y' c0 g
{
: ?, M: j' l( R: o, \% V$ D& ^    if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )3 e; B" ?% x, X; a9 S+ [" ~9 `( ^0 G
       return;
  o7 y" i* ?8 a, ]2 ?& [: k# `7 M' p
5 O" Q7 J; Y+ q4 _# C% p    CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();: b! H6 H; s5 G9 d0 X# f
   int numVerts = inputMesh.GetNumVerts();
; `5 ]/ X3 R6 N/ j4 I! m1 W    int numTris = inputMesh.GetNumTris();+ p& H4 p0 S) u" g

$ @" N, u$ {& {$ N( w    for ( int i = 0; i < numVerts; i++ )9 X" k( }; i3 d# h* M, ^
   {2 p9 k( w3 _9 l, G* k  K0 c
       const CharVert *pVert = inputMesh.GetVertAt( i );, w8 p( l6 z% F3 N. I4 q
       pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;
8 I9 Q; V& M8 e( W% B
! j- Q8 z" v5 h+ y, J        /* u v initial */" t6 ^/ L( D# G, B7 k' E
       pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;: F" G  O8 `0 u" s2 }% A
       pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;' u1 C- X9 d# W4 \5 j" o8 w, T0 D) l
0 w2 V: r2 d% F% h
       for ( int j = 0; j < pVert->weightCount; j++ )
* n$ A( u0 s9 L& Y. D2 ^        {' y5 g9 |$ J/ l% P! u5 S- O, Z" x5 \
           const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );
8 P+ D1 @, K0 E0 D            int index = pWeight->jointID;: m" [0 r+ k/ W) a8 Y9 e% q' s; m7 B2 \
           const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );
! J3 B; y7 u  ]2 {$ ^2 T$ A9 c            vec3_t wv;4 k  m( X8 M. T  V7 n+ p
           Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );
% Q  D( e( F2 n- J9 r0 {            pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;) S0 ?, o+ J' X5 _1 Z, |! U
           pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;" P( q5 R( j4 c8 x& D+ N2 z$ c8 o: i4 [
           pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;" m1 M( Z" R( S% U. ~1 t) `
       }
" n( s, ^4 w2 W6 f    }! D0 v: S5 r) J# A! N. a% q9 ]
  U4 f* P0 u6 S' ?  Q- I$ Y9 [
   outputMesh.UnlockVertexBuffer();
. f6 B  E% n3 [! l- l/ K
( g0 ?7 w2 n" z0 {5 ^0 {; b2 K$ i    CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();
0 v1 H& t! G0 g4 u$ c& b: }6 X7 c% S, X% Z" J
   for ( int i = 0; i < numTris; i++ )4 V4 Z) n% [1 K9 o
   {
% D2 Y, |) L7 e% o6 x        const CharTri *pTri = inputMesh.GetTriAt( i );
- `: u" @& S3 ^  Y  M2 ~' h# a9 r        pOutTri->index[0] = pTri->index[0];, h( I6 o- O/ U# S
       pOutTri->index[1] = pTri->index[1];
/ x! t' E1 d5 B) A: f8 _/ V& |9 `        pOutTri->index[2] = pTri->index[2];* m9 j5 m5 b1 M7 @/ T
   }  c' X) S1 Y; O8 V0 j: g& N# ^
% B4 |3 i3 q  l) r% p5 q7 a
   outputMesh.UnlockIndexBuffer();* p6 x" U% {* ]( s/ e
}& r& s. g1 L! ~0 b- e
1 G/ i; H. M! Y- y# X
其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。2 h% c% N8 y6 `1 E9 u% O

( @  @* ?' v' R7 l$ A% S3 Y3 u/ _; ^: D, M# ?  u9 i
关于md5anim文件+ p( a! c) n! ?1 L; n, V* u

2 ~- m" T5 D  c+ j) _/ m+ D+ h  Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。
; D3 q4 ~* g( d% W/ K+ U3 k; E/ E
% E/ w: j5 N+ D# I0 P# e. h. C
可能的扩展, U8 \6 T7 v* Y* n% k
7 q' p" t7 d  N1 Y" {
一、复杂动作的混合
2 l1 ]- p: C' r& m1 `4 D2 P3 _( w5 t( l8 K/ d
  有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。
; Z$ \6 x! q- l! K* d$ X0 o4 A- H% t3 c8 }" Q+ F- `: R
二、基于物理的动画" x: n4 z' a6 f- ^  f
- z' S. j, I5 j) i+ J4 p; k# g6 F
  这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;
/ R: l6 i+ ~9 B0 a2 V4 R
0 r/ U  y  n! e9 Y7 B+ w- Z三、基于GPU的蒙皮8 H! r+ H5 T8 O2 s9 B7 l
5 g2 U" k! e5 M
  原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。/ J" W, Z' P  ~, E% s, R6 L5 t

# B0 D* q5 g. v0 X四、非常流行的“换装”系统
$ C: ~/ o4 U; x  `/ s
; ]9 S1 H) s3 O6 S" B  这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。
您需要登录后才可以回帖 登录 | 注册

本版积分规则

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

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

Powered by Discuz! X3.5

© 2001-2026 Discuz! Team.

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