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

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

[复制链接]
发表于 2006-12-9 21:57:50 | 显示全部楼层 |阅读模式
    我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。
( |3 x% y+ [8 u% g" V- L
- {, s4 j3 `/ }8 ?3 K1 ^0 F, J在这篇日志里面,你可以获得这些信息:+ o! c- _- o+ m5 J4 B9 O! h
! I. f1 z) i& B9 \4 J9 ]: S
1 人物动画的框架6 _" B$ S! M) t% M& u7 s2 a- x
2 骨骼动画及蒙皮技术& u/ f5 q+ u; G% s, R6 k. ^4 @
3 doom 3和quake 4中模型和动画格式md5及原理  E3 M4 c- X+ [- Z" }
4 可能的扩展
" s7 X2 z( o% \9 U+ ]/ ]7 ^+ h7 I& X6 W4 Z! a  A" j4 J! g$ d, N
, o, g+ V. L5 E3 h
先来看一下人物动画的几种方法:
+ p* _! C2 u3 u  ]7 ^* k5 I/ \6 y& W- A3 ~. e6 }# |" _# D
一、简单关键祯的动画
& T5 ^& Q' {# t- r' C* `
( i% u: u( l! G  像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。
, r/ Y! o$ o9 \9 D- X% u. ~4 w8 K
( E7 B7 S; Z: [! f$ G: S9 \8 k二、简单的骨骼动画及蒙皮技术
# n& U# i6 s' f% L' W' ~/ C8 L
" ?) }9 B' M* a( [  现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。* }" x9 x/ o1 J# s* M2 G
# A: x1 i' E# h' ]1 A
三、改进的蒙皮方法和基于物理的骨骼动画
  |4 E6 W3 I; t" ^
: z/ u$ M2 m- J4 Q  改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;5 g7 a. ?0 t; O9 k# x

, L( _% K1 a# J; [1 E/ N  基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
; F- A3 {  [2 M- Q1 Y$ p9 ?: Q9 I6 f- N9 A# s4 [9 a5 H/ h

# @; M7 O; s+ I3 u2 A9 B$ ?/ J2 L% D- r& D# W9 E
基本的蒙皮原理
: n" R8 C: x% ]7 C2 m0 S  X0 \- a0 H1 p+ G
拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:# e# S3 ?& ]7 }3 O5 \1 ^
5 j; w8 |9 L  s8 p# o
Joint: 用来记录骨骼的关节的信息;
3 F; J# h5 @2 SWeight: 用来记录顶点相对于关节的权值;
. ~- c, p; \4 G% qVertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;
: r( z; @$ ?3 p% W6 j+ K4 i! p
0 f: `- O% `5 T% x4 r' M3 Y- M1 ]现在就来解释一下这三者之间的关系:
; W* F: |, n! y# _0 m; g* u0 M. u$ X! m+ ~; D2 V
  Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。" w' M( M6 T& R9 M( t. f

, n6 u0 G5 `; b; _3 P; o& a" M  很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。. X5 x9 P. s! G
5 I: z: z5 c; f' b+ J3 o2 C
  了解这些基本的概念,下面就来介绍人物动画系统的框架。
1 a' b) r! e& w: ?0 x$ B- x6 J, B- U* V2 u3 K
# Y  Q! k) g9 l* L3 O$ r; l6 E& {
骨骼蒙皮基本框架
  m( g" G/ Q& v; q* U2 x+ X8 w
  m! [) G- }5 K8 z基本的类型:! z/ Z/ g) h3 v& J
$ ~4 [, G% {: R, v
关节信息:
9 _& {1 H$ X% Z! M8 R; ?6 J
2 l) Y( Y' c! F& F6 t* vtypedef struct _CharJoint
- M  \8 m$ U' H' T$ n  o3 j3 a{% G# F' K" P) e/ C
   Vector3 pos;5 ^: S9 K  F! a$ C
   Vector4 startPoint;
1 a- q7 N1 _6 e% m" O' r7 A    int parentID;
, s$ e& i5 @7 D: A( k. e    char name[32];/ z7 W  m; t" V; {+ i2 l
} CharJoint;
) n4 p( x  i5 X; Q1 Q# q) v* @7 F. Z4 s& F% Y  L4 l, R, g" D9 |' p
其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;. _+ o0 D* P" H
8 c) D( h7 L; `) n/ Q% ~. l
权值信息:7 v" N7 R  a: q9 e2 E- z+ R

7 m3 C- H  H1 ]1 M* j  o! g7 b# stypedef struct _CharWeight
0 w9 x: m- y% e{% z$ x3 v! m6 d# a; W) L5 X
   Vector3 pos;! k- s% C$ m8 P( I
   int jointID;
$ W; O. c, R* n. m+ i) H    float bias;* B8 y, x6 d% l# Z2 J0 L; h5 @& X6 h
} CharWeight;
3 c- H9 \1 S4 h: }/ @; ?+ w0 q8 g" Z. f1 A4 a
其中,pos为偏移量,jiontID为对应的joint,bias偏向值;8 Z" k9 b/ [7 J: Q3 a+ z

+ ?, P) @8 O; b/ Z$ E2 F2 a1 s, o! t" f: C7 I
顶点信息:7 a4 {( s7 ^: M+ n6 R" {3 ]/ s

3 D; D, p% j' m% ]5 z  etypedef struct _CharVert7 T5 p) S; o, W* c( N
{
1 t' o# s1 e; a1 }& f: W! _    float u, v;
$ ~' ]' \1 g6 s; X, d& G  {: R    int startWeight;; I/ w; u6 ^) n1 f% ~& b
   int weightCount;
6 q( Z, i% ^$ P3 [& P} CharVert;
; a* [) y' x. ]& u# a' L9 c/ q: m5 X# r/ Z% i
  其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。, J5 o  F, z: \* I( @/ a( d: N, K0 q

" T; W$ E& B: x% E2 w' a7 F( n" D# C; R* Y" }
大概还涉及到这样一些类:# {6 F) D. U2 C, N$ h
- d) ~/ P" F% P1 {7 o8 K
CharSkeleton: 记录整个骨骼的信息,包含了关节的链表;
- S) d5 R5 D* Y6 F- iCharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;' M' q( g; g$ ~3 L# f2 X: c' r8 u0 A2 |
CharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);
+ g+ {+ F, g, I4 G- p* wCharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;
% B5 ]" T! M' g/ X3 O4 }' k8 ACharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);
; J$ K' S% b0 D# |" c) W" I' ]
0 g5 ~$ Y* ]' S/ f0 x7 @: m/ L* M, Y, B% _4 ]+ }
解决关键问题
- l& L2 Q0 p1 J
1 j' {$ F. |4 Y+ l  刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。7 `$ E) H5 _% L) Q

8 B6 ]" T& V! L关键祯混合:+ Y+ O( O4 N6 p

, l4 @+ D! f1 r! _* X5 o+ b  简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);
" X* @  m8 z# Z) U1 d
. i$ t, c! I: s" N3 }  际应用中还有其他的混合形式,后面再来介绍。
; ]- b' s% @) q8 d7 I' h, a$ c- T; l) m7 p" g- T
* U2 m, y5 Z. ]6 j
计算实际顶点:  C& Q7 o- M' G

: F+ ~6 w7 |+ K) ~7 ^! R+ I我们看一下软件的(用CPU做蒙皮)Blend过程:
( J+ z- `: M/ r+ G7 Q9 H. C( L( {0 _' Y
void CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )
- B6 r7 G6 C& @4 E8 ~{
8 A( g2 S; r6 P/ d" P) p    if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )6 Z: E* u& g" U" G7 l+ I
       return;' _+ r/ Q$ n) S& f% g  |
9 e' a% @- P$ P) I
   CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();/ ^+ n9 v) F3 L$ Y9 G, ?9 c+ h" N! X
   int numVerts = inputMesh.GetNumVerts();
- W7 g% N  @8 X4 e7 _    int numTris = inputMesh.GetNumTris();
2 m$ t2 L4 \3 F0 r6 w4 X" a- u
2 M3 s$ K" F/ h; b1 p: D    for ( int i = 0; i < numVerts; i++ )
/ c, Y( p" @9 m# ~, i0 x% o. e( i    {# D+ ~0 w; r1 `. l+ [* B
       const CharVert *pVert = inputMesh.GetVertAt( i );: P  q( B& z5 Y* i
       pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;
( y1 g( @8 [$ B& D4 z/ g3 \6 s! F) ~. R  J& U, `
       /* u v initial */
) _- P7 h# d: `1 S8 d        pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;+ A9 H( S; A* M1 q+ T
       pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;
+ [9 u4 d0 @5 c# G0 O- _1 y) i6 c
' X7 ?/ O) j; ]' Y1 }, D        for ( int j = 0; j < pVert->weightCount; j++ )
+ F" d4 S6 A* S2 b        {
! P/ A, j; C2 i: I* z* y            const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );6 l5 P/ u$ t# z# N! u6 D" G
           int index = pWeight->jointID;
+ B2 w8 d" Q! w            const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );- w( t1 G- i& \
           vec3_t wv;
0 x2 C+ L/ I# E" m$ a! l            Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );3 g9 Q9 G8 O3 r* F$ Z
           pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;2 C4 p# g/ d" @  C
           pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;
% n; e( @2 @  P# k& i5 M            pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;# f- c$ u% {3 e7 y
       }
- T; E/ R. _, m2 c4 q, P+ `! z    }  [0 Z/ Y) q) m, l/ V1 y; x
, Y4 C) S6 q5 @2 D# V  z$ Q% v1 g* Q7 q
   outputMesh.UnlockVertexBuffer();
* i0 i) Q9 F1 l3 O$ p9 ]
* C% f' v% O6 K' n% m' P; D    CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();' w. {$ M8 o6 ^& M
% j, ?6 z% t! m9 t* D
   for ( int i = 0; i < numTris; i++ )
6 _, K3 @) g5 ^* I. D5 ~    {" Z5 c3 P0 Z: m$ V! f$ L  ?
       const CharTri *pTri = inputMesh.GetTriAt( i );
" M) N, d3 g8 @4 Q; d5 j3 V+ z        pOutTri->index[0] = pTri->index[0];
; m/ g. D+ ^( Q/ ]/ b& j4 q) B        pOutTri->index[1] = pTri->index[1];
& Z; \. S3 M3 M, K( j5 u9 i8 e        pOutTri->index[2] = pTri->index[2];
+ @  q8 P6 Q4 }' P/ X7 `    }+ A3 X5 g2 ^7 x! f

6 q1 O7 g# E( U$ b- u1 a  b5 W( I8 q    outputMesh.UnlockIndexBuffer();: h4 H; t% e) P0 n
}
6 n1 U3 C  |, X, r, v: v
& ~2 f6 L6 b3 W. s7 b  P1 A其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。8 H9 u7 b" T2 A6 I/ m
+ g* E9 E+ W: O' r& X( s5 q
  v# s( U- |" G2 L& u) c' F, A
关于md5anim文件, q% R" b+ r  M' Z0 }: n& y7 j

( o/ t' q2 @4 S1 _  Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。
/ D% `# ]! \  N( [0 t* h4 V) M" {

: z( T, M9 z$ P" t可能的扩展4 ^6 Y4 _) M4 n! I7 H, r6 e- e
* {1 d' T3 o; O+ y
一、复杂动作的混合, r  n( ]6 a3 X$ Z

' @) |2 S* k/ T7 r2 I2 U" J  有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。
7 h' m1 ~1 P, A9 |& ^) b+ x$ l% S$ a; t1 L2 o
二、基于物理的动画
$ m7 G+ f/ P  f2 \9 u2 [
- M2 B" U8 a/ O& o  这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;1 f& r) b3 i) b% C+ Q* Y

7 B4 N  g* N& X6 T三、基于GPU的蒙皮# X( N) a6 Q9 c* [  j# ]1 ~) i
% e! \  l, l, Q" a
  原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。
5 `' h6 m3 e2 L" Y% X5 f+ M0 _0 _( t( |$ j; g( L
四、非常流行的“换装”系统" p% W8 s1 [/ b1 x% V

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

本版积分规则

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

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

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

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