|
我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。
- w$ q! o8 ]+ R, b6 W0 m: M) ?$ h( f
在这篇日志里面,你可以获得这些信息:
* U* n: y3 y- Y5 ]# e% z: j1 H0 M# U: f2 u2 Z& W6 P
1 人物动画的框架
8 L& w* y8 l- R& Z" s! A! w$ D& |2 骨骼动画及蒙皮技术2 x, q6 j% p* e _
3 doom 3和quake 4中模型和动画格式md5及原理
! S& A3 ?& B* _: i( q4 可能的扩展
9 }7 B/ B1 @. V# ~$ ~
% k( c' X* O3 \& P
) \. A0 F" f" T" W: ?* c先来看一下人物动画的几种方法:
+ d! F4 e# Y8 q! \
1 t, H0 z6 D" h3 ^5 T" z一、简单关键祯的动画: S5 p- g7 a, S1 [9 H0 w
! K4 T. i; f& [ 像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。
' ~' a- h) t- C
- U! d9 n9 E q* _/ s# A* J9 i二、简单的骨骼动画及蒙皮技术9 M$ @) l0 [* D* M# ~3 A9 F7 S
! I" F, \2 s& j$ m4 j: J) Z0 u 现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。1 A$ [4 H# d/ B* s& q9 N# Y3 ]
0 \1 o7 x% g" X9 o; s
三、改进的蒙皮方法和基于物理的骨骼动画
* K* J( g4 j, w1 _# K3 a/ X' K. h& E \. R* K: `( z
改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;$ u( \, J, p1 E5 I- D7 I. `+ H/ B, J1 L
# P+ N( U& z9 U- Y. ^* M; B 基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
! L$ S$ W |) @2 z/ x& r$ P- L5 G" S
9 r& f) Z2 M( p& O3 s% x2 G
7 L+ `7 Q& B. s/ j" S
基本的蒙皮原理
* f- Y; C4 A6 d4 O
" w! R6 e! G' R0 P拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:
( H9 p8 k( @8 O1 k) g. z+ B
0 w& v$ i/ T7 S' rJoint: 用来记录骨骼的关节的信息;
* f9 l& b0 t" V% G; ~. f3 bWeight: 用来记录顶点相对于关节的权值;
6 g: d( {6 T2 r8 P) ~Vertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;
+ o' l+ W1 \6 O0 k A) d& P6 W, P+ n% T* L3 c
现在就来解释一下这三者之间的关系:
$ w) q- H8 T9 D3 U+ C* J
! e* C) g7 t$ W5 j c* M, t Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。, b! |. l9 @" t H
( v: T+ x. P- ?" X1 \9 a8 h0 W 很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。
9 P+ J R# i9 ~0 ?% F" f0 c6 t2 N2 w+ |( n0 v
了解这些基本的概念,下面就来介绍人物动画系统的框架。 }3 F* l7 m) Z$ ]& w/ k6 B
- M, G# x+ B" B* ]: b9 u
a8 ? y- M1 Q4 A, S骨骼蒙皮基本框架
/ w) ] e- Y; |8 s7 ~+ g, U% c: ^4 ]- H1 N
基本的类型:& X2 w- B. m4 v1 N+ I
( {8 \6 m+ V" r% |7 [3 }关节信息:
( Q* i/ x% X6 L9 O" ~1 s% L- z6 I+ J+ ]5 D8 J% O6 G& o
typedef struct _CharJoint# ` ]% G& u* p
{# o* H' u+ o: L: @8 j
Vector3 pos;! T ^7 ?* r# u# h: ]
Vector4 startPoint;
# v3 e' e/ _0 L/ d2 F$ W5 M int parentID;) f* k+ ?% |- B6 r
char name[32];/ R) h# g4 l6 }8 X6 n7 I
} CharJoint;2 O* W! x6 k& J9 _& P+ R
, k! j$ X7 l* ]" {4 c其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;
; C# C2 R6 [9 e: i e& L) L
& v9 T; b8 X. e$ e权值信息:
" g. w& ~% I% v8 I# ^; o" k% c- {9 w, k6 T6 A( c
typedef struct _CharWeight0 u( M5 |2 I: I2 @4 i# v
{8 _8 R* D/ E6 i+ H& h1 x* U2 M
Vector3 pos;
# o) }% c% b3 R& [ int jointID;
1 Z! s* y9 I, R9 T# ^' s float bias; r% P8 Z. W c; w/ u! O( Z
} CharWeight;
% ], d) F' |0 W' p
$ ]$ T3 e. v# A3 m其中,pos为偏移量,jiontID为对应的joint,bias偏向值;0 N/ B6 f, \: P
9 F5 A& j2 `' M+ J- y0 G7 X( l$ H7 F0 E
顶点信息:
) w' d- W$ b# {, k2 C' |* ]5 }* z4 `$ y6 w
typedef struct _CharVert
. F) v8 Y- e8 s! N{
/ s4 _) F1 n! S2 B3 o% O float u, v;
1 p3 @ V, K: } int startWeight;
8 A, q8 k& z/ k5 b int weightCount;
8 y( _& \) ]$ w: u} CharVert;* S8 I& m( A& D9 G9 t% M$ g
8 }8 A6 U* c8 e4 l" b. H
其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。
" g+ ^+ |. W* D3 u0 i6 m. _. G; J1 g
& v& A$ |1 O l4 B) K/ w大概还涉及到这样一些类:9 G6 _) i- O: X* Y4 g
! |# W7 g5 ?; v/ RCharSkeleton: 记录整个骨骼的信息,包含了关节的链表;
6 B6 W/ {/ U0 T+ p0 pCharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;9 s) e S% o+ n$ G' D5 T
CharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);& h o$ R2 j4 s; N! T2 }
CharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;
. ?0 L+ [6 ]0 i% Q1 u+ ECharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);) ~+ ?( x9 N) T. D4 Y
( b: E& b2 [# f0 Z1 J2 l* T$ U* L) E) p
解决关键问题( E) ~4 R2 n. P5 M! z( o) P
8 E& n- W8 X- F 刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。
2 F9 N. b% w' x6 W' ~1 S# j- h( B, a2 o; ^7 C/ S
关键祯混合:
+ X) S F: Z0 p
9 B7 j# J) G( O6 A% e* q 简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);
- H$ O% F7 w# m- l7 M( ^! s3 K( F( b+ W% l
际应用中还有其他的混合形式,后面再来介绍。0 A8 }7 N: Q* P. C
8 W# W" P6 u1 z q/ o' P& R! b" B) |% v8 b& v( L1 M0 R5 ?
计算实际顶点:
7 s3 I6 t; [1 O B0 |4 j4 `' [2 C2 g1 V5 y# ?
我们看一下软件的(用CPU做蒙皮)Blend过程:
5 }* Y: H- f) M; C+ p9 l6 ?" y; R7 z( y
void CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )
! e" I; l" t+ j7 B; Q3 x% m{' W8 O" A4 X3 j, t8 c8 X
if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )
1 @, H& x' s% l) R return;
) M/ m* m# I1 Z3 q: I1 J! Q* h( y; `, f' m! C7 l4 z
CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();( ~! R/ C% X1 Y. l: I
int numVerts = inputMesh.GetNumVerts();- C, l7 H0 j8 f1 j& b
int numTris = inputMesh.GetNumTris();, L6 g' e% Y7 u- p4 T
, g. X6 _. b' A4 t6 [ for ( int i = 0; i < numVerts; i++ )! B' |! O# k/ W. U% T$ `% n
{( R5 P _+ ]+ D* `0 l. B: ?
const CharVert *pVert = inputMesh.GetVertAt( i );; s1 m) n% J1 W; D, \, ]. Y
pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;
$ }) K% D* g+ T* X0 m
* n: G) b) [8 `& v /* u v initial */
" B! r" z2 t! }# r* B4 i8 b$ v9 b pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;
% U' Z/ C$ W# J" e pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;
: s2 r$ v1 a- d1 N
) [, H Q8 o6 P3 _& ~ for ( int j = 0; j < pVert->weightCount; j++ )
# Y) z6 D; R$ d, k {1 a5 o `& g" Q2 Y
const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );3 x# y9 _' r+ f5 a; I, A
int index = pWeight->jointID;. S% L& A# s- l* r
const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );
( a+ W) D/ T" `) M% V- J% q3 z vec3_t wv;3 u' L- }+ x u9 I% _7 F
Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );5 g+ r+ | U- h% a$ F& k$ H9 L
pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;+ S5 h$ s q, q
pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;" b Q; f) B% o! i4 ]
pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;( c$ z R( F, b! G5 _ i( ~ L' @
}7 J# V- i/ {' T! b# B( e9 s0 A
}5 M# e# P9 B8 ^- s/ p- k) e9 Q1 {
6 i F0 S# z1 ~& t" ?8 X$ c* @ outputMesh.UnlockVertexBuffer();
6 x% M# f- [. b* V! L) F8 T! _. x# ]4 |% U
CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();. g- o. n( N* @8 w" @- b% b- p6 G
L# d+ G7 Z* P! }0 ^& y for ( int i = 0; i < numTris; i++ )$ Z( D% w+ u' Y# n8 v7 n) C# u- X& O
{
& \5 s" l% A/ m" E# ? const CharTri *pTri = inputMesh.GetTriAt( i );% E$ A! _7 p8 p
pOutTri->index[0] = pTri->index[0];
" B: n/ M/ B( k/ |; p; a pOutTri->index[1] = pTri->index[1];
( x/ `+ t# o- r: P7 U) H# ^ pOutTri->index[2] = pTri->index[2];
4 R: k: U$ A% k6 o& _' k" m' }4 v i }
' T0 i' P/ G" A$ O1 r. K e( f( D! o m
outputMesh.UnlockIndexBuffer();, E! C, C4 h8 J9 Z0 v6 V8 V
}" a$ }) V9 ~ Z+ ?$ m) D
' O { @ p1 l- W0 L" [4 w其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。' f* f" y5 l6 B1 t4 q! T( X
7 `" Z! B" h/ w: J! `* [. b
9 Q& Y. r( Q* N; [关于md5anim文件
) x5 a- m+ z/ ~9 k( L9 b: O7 R: b$ A$ f+ W; d' f
Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。
5 U, s" y& c' X z8 s4 \# p$ S5 P6 `8 |1 B
# E$ h5 `& e& K* u
可能的扩展# }2 E( s3 M r& c
7 o+ z9 @ \$ Z% G
一、复杂动作的混合! S, @" g/ s! @9 Q
, m; E: f- x. a8 e: {0 H. c% x 有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。
$ F( j( R0 _8 B& z! Q
+ L) X- w- ]# G0 S二、基于物理的动画
& c d1 @$ b R- w. T' @6 B: t; x+ h! s# A* Y. m7 @6 A. J6 D
这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;2 E: t0 K& B3 \4 n+ h
" m4 j9 W. G" o6 O
三、基于GPU的蒙皮
+ `+ a- h3 G# S. c) ?& J% F
% x. k/ v! k/ q7 \$ `! Y 原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。( S3 E) F. x# h% d: z8 A3 K. G: J
% W: K; o. s" |9 o m/ C
四、非常流行的“换装”系统
5 S1 q' i) U/ [. l+ W8 R. `* ?2 ^: R8 v3 a
这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。 |
|