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

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

[复制链接]
发表于 2006-12-9 21:57:50 | 显示全部楼层 |阅读模式
    我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。7 x" [4 d+ p. _7 ~- }+ }* [# e0 T

& t. x+ C4 c/ o) E; ~在这篇日志里面,你可以获得这些信息:1 s* X! U% E% \5 v( p+ u0 x! T
$ ~$ c  L; C2 I+ j
1 人物动画的框架6 D7 T2 ]. r! ~. \$ H  l0 w
2 骨骼动画及蒙皮技术
& g+ G$ R/ m" a$ d' @5 ~! W3 doom 3和quake 4中模型和动画格式md5及原理) [8 s" }8 k; E2 h0 U
4 可能的扩展2 G2 O( X; D. K, P5 K1 J

. h7 \$ ?/ X# H  U0 p; ]
6 G# C4 a+ T, a7 @+ O先来看一下人物动画的几种方法:
: F: k' s9 y: G: M1 _0 a
" @  |! G9 r! v- `4 h; V一、简单关键祯的动画
+ [* W4 ^, ^, g  m- _5 k
7 ]3 [5 h  Q: l/ X  像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。1 {! M: M7 \, X( |' |
4 T* N' h/ Q& x+ N1 ]
二、简单的骨骼动画及蒙皮技术" p4 _# l# ^+ T# W2 f. L

- e, ^, x* ^" C; T3 S3 W7 N  现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。
! S4 c- O+ z7 s! C3 O4 w, l. M1 E! p  E1 J3 T2 j+ F! ]
三、改进的蒙皮方法和基于物理的骨骼动画8 q* C3 d: F0 V' ^" M. ~! m
0 ]- `" v8 G! Q! V
  改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;
( P8 R1 q$ e' ?& @$ Z& q( E! Z/ m/ Y7 N# ~9 T
  基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
" n* T; }! q7 r% P
0 ^- b3 i1 A9 T
% W1 q% G3 d* C: z0 Q6 [& U/ _" E$ b
基本的蒙皮原理4 N1 x- R; ~0 Z, m

6 K2 w! u: L! R1 k4 b. g拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:: x1 r8 {- A; D4 e6 ]3 }+ C+ c( b* `

3 F3 S/ o& \* o% w3 q0 Z4 KJoint: 用来记录骨骼的关节的信息;  ~6 R: [( h% X
Weight: 用来记录顶点相对于关节的权值;
5 r( t3 D0 T9 X% ^Vertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;
8 n4 Z- C4 g, q5 p7 w1 O, U, _( [" W, ^9 C) S
现在就来解释一下这三者之间的关系:. ^/ H6 p; K! y2 Y$ J
, V, J4 b' F$ ]
  Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。" z1 r% `6 b$ f8 M
" f. Q3 d+ {0 h3 q7 z2 t5 E
  很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。! F  h9 Z5 L- T' e, r
% z# J# k& f4 l$ y3 k- t
  了解这些基本的概念,下面就来介绍人物动画系统的框架。
$ N" O- f8 |( n0 @7 Y
/ j* S9 a- M+ R- y
% g7 @. R. M) W- C骨骼蒙皮基本框架: o& e* |" X  ?  S

/ U2 Y: s+ y* Z' d) W基本的类型:$ i0 y& d6 \4 H- l% v
# \" B; Y1 Y0 _7 G! W  X
关节信息:
; B2 \( C5 m' A, j& o, v3 G' s) {- j; ?+ @. v
typedef struct _CharJoint6 G3 M0 u6 S+ B7 P" w$ X8 A$ S: c
{
) l- v0 h& k4 M* _  M! {  M    Vector3 pos;- @* |: r% Q3 I1 f/ o
   Vector4 startPoint;# O8 S  W1 G" [( A
   int parentID;
& B3 M9 _' q8 B    char name[32];8 L2 j( s4 H7 O$ l1 s
} CharJoint;
6 z3 A" G3 c0 j# f
& e$ n3 M0 v9 {8 J% r其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;5 i: p5 t& z7 T: ?) d
/ M# R5 s  o0 {! u1 d2 Z
权值信息:
: y' o5 n1 k+ Y/ H
* z0 l" l6 R7 P3 X2 z4 N! v  Htypedef struct _CharWeight# l6 k* K: N' U4 k) a6 ?9 h
{
+ ?2 c. w6 p1 X" C    Vector3 pos;
! y: f6 `: ?- [    int jointID;- r( H, d" O  D: m, A1 r4 p4 Y
   float bias;
  N8 G6 x$ _  W} CharWeight;# F( j" o# f" N2 w' x3 X* J5 f

( e% z9 m6 B& n9 n其中,pos为偏移量,jiontID为对应的joint,bias偏向值;; w& p& P' s3 p( ], ]
6 P+ w4 V7 k9 |8 A2 c

2 M2 ]6 C4 n7 O6 Y* K3 @" ^顶点信息:
  I1 ~8 w( [; T; O
$ C% x% u; }+ ?" o/ M9 btypedef struct _CharVert
; j0 R* I& N9 V; R  T+ d{; L. D! L  m" ~6 M: m, U' {
   float u, v;
. X) P, {9 l9 P6 }: {4 [    int startWeight;3 Y) ~+ V" t1 j  u5 }9 P8 f
   int weightCount;
& G5 a" w# i6 u' y} CharVert;: _2 c1 H: u; M* W
2 b# e) l) f5 a& t3 _6 W' J, ]( q
  其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。* k  M0 W; `/ G8 P

1 ~. q! D2 c, n0 W7 N5 S0 R
) ~  Z# S$ R% ?+ D大概还涉及到这样一些类:* O5 m. E9 i5 `: g+ s3 B, D

  h- H* c% _* l* l6 {2 fCharSkeleton: 记录整个骨骼的信息,包含了关节的链表;6 r5 |& J' ]# Z# r! A+ T
CharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;
% s. ?" @. A% l8 r, }& jCharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);+ ]2 S' ^2 ^# a
CharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;
8 B: F: ^& W4 d" _3 `CharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);
2 p* J0 {+ Y  f3 R( X: G$ }
! N5 x) o6 a5 F2 _: W* F8 ^# Z7 K/ b* B, D8 Q( q! }
解决关键问题' L8 F# q9 m! W" q& p* {
5 F" `7 U4 u7 f4 z$ m0 J% w: G
  刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。
/ p! p' z. C3 D, P) Z0 `- p( k& S9 D1 v$ x& t9 T" J6 s
关键祯混合:& q7 @/ W) p4 v8 \
/ i4 U8 q; X7 s5 c  y
  简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);
1 Z  i7 @" y* s; P! c
$ ^3 Y2 Z/ @8 c, @. E' Y+ K  际应用中还有其他的混合形式,后面再来介绍。
" e! D- g. p& O4 t% J
! ?. i/ Q. R  G! }) G  s6 C& i' L1 O- t9 z7 F. X# v! v& W& B
计算实际顶点:* m. P+ R3 K/ p

3 Y4 C7 H6 O1 p& i& ~' O# y. K我们看一下软件的(用CPU做蒙皮)Blend过程:0 e# v) \" \1 Y+ M5 x
$ K" j% \5 c8 [' ~* \* B
void CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )2 e; |0 T) |0 h. t
{& j  A7 r% a  b$ `8 C
   if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() ); m* i' a1 w1 N/ k# k
       return;& A) D! h1 T+ H1 O! O
8 ~! F8 I( g/ `
   CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();# w% e2 v# Y! t0 D
   int numVerts = inputMesh.GetNumVerts();
4 O# V& @5 @6 S& Z9 T5 t    int numTris = inputMesh.GetNumTris();
! M, Y9 ]; z( @! j
$ t1 R! v; U6 h# Q    for ( int i = 0; i < numVerts; i++ )
3 y  M9 ~3 ?  R: n3 H! A+ _! U+ O    {
; r6 X5 k. z, u, q        const CharVert *pVert = inputMesh.GetVertAt( i );4 p5 r$ d& R" c  I4 Z
       pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;# \' o" m4 O- P* s) L4 N
8 c- C8 u3 ^. I. y/ y
       /* u v initial */. F& c' B$ o. e, |$ z
       pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;) F, u; t) K0 k7 s5 O( F0 f, `! H
       pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;! \: d' v8 n' Z2 O' X; Y

+ ?& h4 {1 j8 J# n+ p        for ( int j = 0; j < pVert->weightCount; j++ )8 L8 g! C- S& m4 B. R; o2 `. E3 v9 J' c
       {& p" v% X* l, j% s) s6 z( {
           const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );8 N) t; b6 i1 K. Q9 S
           int index = pWeight->jointID;
) p. }' T7 Z; V/ ]( z            const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );( k# A( Q8 V. \. G$ n
           vec3_t wv;
9 N* l/ H/ t6 [% T  j# z, p, q3 L            Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );
% e7 H. X" f9 B            pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;; Y0 d$ t) Z3 j$ y. G
           pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;
" }8 i8 o* E+ ?            pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;
+ z' i# e$ J8 X2 _/ O# W& s        }
) m# E  h! S# x' i    }1 C8 c) w' u) K- y* `. {; i

: g8 P* @" d2 i" k% G4 ~    outputMesh.UnlockVertexBuffer();6 f  j3 c8 C+ w1 ~

! S/ W) e) x1 M    CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();
4 [: H" H- @7 ]9 {, S& x0 Y# Q% n4 {5 n. c/ A) B9 x; _& D' ~) L
   for ( int i = 0; i < numTris; i++ )
' n: ~$ B) |, E: J/ p7 O    {$ A* P! J+ t: P! e+ K$ o; z: J9 f
       const CharTri *pTri = inputMesh.GetTriAt( i );( `5 k% |# R3 ]; O! g
       pOutTri->index[0] = pTri->index[0];$ E. H% l; y. C( a
       pOutTri->index[1] = pTri->index[1];
9 ~/ D7 d3 e& W        pOutTri->index[2] = pTri->index[2];' S5 G. _: ?8 c- k
   }
* e. c/ B) Z/ {
; Y2 V6 O' B4 ?0 R! s, a; H9 ~- V    outputMesh.UnlockIndexBuffer();; g; N. M; F4 T" |* O
}
) N, i! m* b' |) C4 t7 X* S9 v% N7 S% y+ M+ K  m9 a
其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。
  [" s1 s  l# Y( p% p/ }" j
7 V. x! ]  T0 U' Y) p+ B
% o( N0 {- w3 h6 s8 \" R: g, {关于md5anim文件$ {* M) D/ ~+ `% v( [& S9 D

% J! c* ]- V3 h, Q( J" D  Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。
8 w: s5 R- c( @4 M$ h, {: i( Z  |' T2 ]  _% ?8 i0 m  p: F) r
9 d2 G  j" Z, t: T) S5 q! R9 f
可能的扩展2 Y; ^0 G1 O; c; r

# o- O/ X4 ^9 H7 z一、复杂动作的混合
, O9 M. O* p) l: Y! ^" v/ k! X+ |- V& `! O  o$ o
  有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。4 I- u% i* {% o1 ?

+ t) J9 H* z) u* j% }4 n9 N二、基于物理的动画
% E( C6 S; \& I+ s# O. ~4 H
2 C4 }. ?2 D6 _0 m$ q& Y/ M  这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;
  H# j1 K8 H) w7 W- l/ t: u0 a, m8 G: L( q2 X
三、基于GPU的蒙皮
+ W1 P- i- J& ~! A( |) Z& L/ m/ ?
$ l: _5 a! V* n  原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。9 f; C9 @9 w/ U1 b
& m5 y# f# g; D' D: R, S$ i4 x
四、非常流行的“换装”系统8 V1 Y! H( v2 Y9 d
& ~* a  S+ U0 G( @% b: [
  这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。
您需要登录后才可以回帖 登录 | 注册

本版积分规则

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

GMT+8, 2026-6-18 09:19 , Processed in 0.018994 second(s), 15 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

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