|
|
我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。
5 G2 e# W$ ~7 W7 W/ v
; H) Z% O4 e, v8 K q" s4 s在这篇日志里面,你可以获得这些信息: N5 L; \0 S, |4 H1 c7 a
+ ]$ O1 L: D0 ?3 |% w7 [! c1 人物动画的框架
* X0 Y( s) E- @3 K6 J; P2 骨骼动画及蒙皮技术8 ?9 e! M& j# U
3 doom 3和quake 4中模型和动画格式md5及原理
1 p# |% ?0 b% }& c( @4 可能的扩展6 f- \9 b. P8 I4 _' m3 D) s
, t4 q7 N, N% J) E4 C
7 K5 T3 ]5 R4 v: T/ k先来看一下人物动画的几种方法:
* _$ }! _+ x& B, u* u) @, L( Z+ ~" f P7 J6 k/ |( a+ X+ ^" B
一、简单关键祯的动画. u9 y1 y* @& k9 v% T+ k
! u. N9 t/ f# T5 q 像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。% W' Q1 V8 G. E1 i1 i2 P$ X* T" O
" h' u+ ?8 O1 `; y
二、简单的骨骼动画及蒙皮技术7 \6 y. N/ {( K! {
3 N' o3 Z5 R/ z( X 现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。6 i5 S: g( U4 ^( E9 j" v6 _
8 ?8 ^3 Z: f3 [
三、改进的蒙皮方法和基于物理的骨骼动画& O5 [, G5 Z9 c+ k& i7 ~5 m$ |
G/ U$ l$ ~7 P+ H8 i! ]; z
改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;$ q2 p' B1 O1 W, e: O( [6 M: J/ \
: |: u7 ?0 w9 ^; g- I8 ]/ O+ | 基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
: R8 ]. U7 [0 o* \9 n7 @, d
$ q+ T% `( W. |. s7 Y2 i# ^& h" ?4 `# h2 C+ h* n1 c" K/ L
! c8 s) w. c2 `4 y9 m4 q基本的蒙皮原理9 g- u, s, q* i$ L8 r" ]
- j% q) Z3 q# G4 I( K7 s# J& Z6 E
拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:( f" }+ U4 n! m+ Y8 P4 g6 c0 C3 D
. |2 `* \. i3 |2 k8 j% p- ~
Joint: 用来记录骨骼的关节的信息;/ R' @ y; \6 N
Weight: 用来记录顶点相对于关节的权值;* }: D- w0 |+ }1 d! D$ H
Vertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;2 p9 w( E1 n0 `+ [1 R" W0 \
8 l* E' c% l3 X6 m% Z) X; @现在就来解释一下这三者之间的关系:
0 U \. T' H+ I4 B o0 [, K! q2 F8 Y% u+ o7 z/ g
Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。
7 B3 J: a0 C6 }( }. J
# `% G; w7 [/ K1 T6 Q! E( T 很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。$ c1 G8 r3 M) W1 [5 k
* _# l: G q4 F1 Y3 P0 w2 i7 g
了解这些基本的概念,下面就来介绍人物动画系统的框架。# A, _ H$ g" i, g4 L2 w
& I: ]; B8 T4 L! R: K, b# Z3 e; }7 r# r5 M/ X* t) z9 g
骨骼蒙皮基本框架
C# M$ }& K+ i# f e
" D$ x0 g/ {0 g) g基本的类型:9 r; A* L( l! q) E
4 Q1 M& _7 e- P2 h% Q6 q
关节信息:
' q2 Y: s& B* _: o
& G% G# y7 I$ M3 y" H+ Ztypedef struct _CharJoint
" D$ Q8 ~1 V0 m9 z! `{% u. Q5 ]: H z5 r
Vector3 pos;
n% d! H, I- V' e8 y Vector4 startPoint;# B1 ~7 F/ A3 j7 S' O
int parentID;4 i. G6 D4 s2 f$ f2 t
char name[32];
: [, `9 _+ w1 V7 |: Y. L( X} CharJoint;
! x2 I3 O: u2 o- E. ]7 l
$ L2 ^" `. b' m" t/ `3 L其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;% r0 R7 Z1 }( k* b1 G3 _0 t
" \# I& m! H6 v+ e" l权值信息:
4 |: R" q+ q4 M1 b" _' Q: w+ Z" t P! b% q2 K; z) S9 q2 d3 o0 `* C
typedef struct _CharWeight2 Y5 J5 E+ H d+ X# Y
{
1 {1 ] }4 `# l1 M% a! y0 N( h1 O Vector3 pos;; E( a ~7 t# ?2 `' h2 @7 M. Z
int jointID;9 F, J! i; o3 n" D6 h
float bias;
5 K+ W5 F, A. r" g! f6 K+ ]' D3 q} CharWeight;, V9 A" B) u1 E+ O
, z0 S1 J v4 V( ~- V9 r9 X# ^
其中,pos为偏移量,jiontID为对应的joint,bias偏向值;8 H7 I! g3 x5 @( |
* v9 [5 h1 D9 `+ E& ~6 F9 {) C& [$ ]. X/ d1 F4 D) x6 J
顶点信息:
/ w# a+ t2 n3 q7 m
" @+ }3 C2 Z5 X* O4 etypedef struct _CharVert' t$ m3 x3 s+ V9 ^
{) S2 V7 b7 y+ i$ Q9 A& d
float u, v;
, T+ v# `1 j8 g9 Q0 I3 E int startWeight;, h; B4 U+ @& W2 K% l" W5 s7 ^' T
int weightCount;
% v6 I9 |$ j) p* E) J: q( ?} CharVert;
9 g' f! H$ J% V9 ]! P
8 d8 j: [; S$ y) H8 {7 y 其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。) ?% X% G7 ?2 J* Z M/ V
- I2 _# o$ L9 } H
, S$ Y/ M" Y( R6 v2 O$ l
大概还涉及到这样一些类:
; G% j4 Z- V' \. z( @. m
) |, I8 A7 I# |2 q% eCharSkeleton: 记录整个骨骼的信息,包含了关节的链表;6 M W* R% o. d9 f& }
CharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;% z0 F+ W9 |& o, A: z, h
CharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);, ^. g; F( A3 ^ n# Y
CharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;( Y; ]$ z f8 w
CharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);7 n/ k: P3 i) g; m7 f) k
8 s( x! b" n) Z8 Z
+ j, s9 r# L; V p0 k解决关键问题5 Z6 x4 z6 m6 R
F3 [ \( C$ Y _; |6 Y 刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。
5 B0 p% d! [2 Q* y( o# c4 w; A8 v, e% [, o: w* E8 Q% D
关键祯混合:. J# D& A* ^4 X3 W
/ g% q& s" _: u& K$ Z( Z 简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);4 a# R% r8 z* ~
/ | p. Q* d' ~
际应用中还有其他的混合形式,后面再来介绍。
; V' _7 d1 R, y8 f6 ]+ w$ T! A+ B" o2 @0 Q. I+ c
6 R7 q. W9 T6 K. B4 s, s; I
计算实际顶点:9 @1 Q/ ]% B9 D! n
( H. X8 ~) \9 c. g
我们看一下软件的(用CPU做蒙皮)Blend过程:- H, B2 k8 z1 {4 p% [$ t6 i
" B1 Q3 {8 r0 I9 x9 b T8 j- pvoid CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )) x2 V9 I) w- ^9 w0 H5 ^( x/ F
{+ w B2 ~# h# F7 x; n X/ a
if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )9 ~7 w) [' h; r
return;# @9 }5 a7 M, }# j- ^7 V' k$ M# s
9 p# F/ f8 l' ?2 }+ V( u9 |# Q
CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();3 B5 ?% B& p0 h, B! I! ?
int numVerts = inputMesh.GetNumVerts();
% P, N$ A8 }% @! q8 M int numTris = inputMesh.GetNumTris();
8 l0 Y2 ~) y+ v8 V& S3 c
: L% {- W9 a q7 @9 u \0 Y for ( int i = 0; i < numVerts; i++ ) [# Z! e1 T; _) v; b& i3 j# F8 @
{
2 D: z: l6 ]+ B( Q' ~: Z const CharVert *pVert = inputMesh.GetVertAt( i );
. ?; k, o D4 o4 ]8 Q1 Z. i( w pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;) d5 F" P9 t6 y+ h, s# b, [
4 c4 G) }1 }3 C9 A1 j. u /* u v initial */
7 W g; P1 ^8 |- ?: n4 r pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;5 M- n" f* |7 A( \. P- c
pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;9 m$ W/ w, H3 L) C" S
# n! z: b0 s2 X( X, m6 G z: V
for ( int j = 0; j < pVert->weightCount; j++ )6 G' A3 l3 _) ]! p" E* }
{- v* i. \- D& V m
const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );( A2 |% ]( u' H
int index = pWeight->jointID;1 m; K3 {: @# e! X
const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );6 T( U6 P% Q q! n/ N
vec3_t wv;
9 O# y; x, e, f! s+ f3 E0 b Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );4 c' q3 N* b3 @
pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;; x7 L# i* Q5 [' ?
pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;
6 U& |$ [# C. [: B- J5 i3 \ pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;
' W, o0 p- H' N7 I }" m% ^4 V/ a% e, {+ K
}& ^- \0 V. E8 r6 O6 p. o
/ |! N Y1 F% p) w: z. ]8 ?8 X, F outputMesh.UnlockVertexBuffer();
3 o9 @; g: |) s2 o k7 _2 a
1 }/ _7 Q4 B, y5 h5 d( N CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();% P/ O% h" M, ~7 r' i! G: v8 y) P8 S
6 I( N/ z+ J, I; V2 a+ }8 }( ~: e
for ( int i = 0; i < numTris; i++ )1 l* \/ _3 a. J9 T( U+ C
{
) Z2 U2 x8 A, n/ f3 L4 j const CharTri *pTri = inputMesh.GetTriAt( i );2 n& E$ c% r' l' r1 l3 S
pOutTri->index[0] = pTri->index[0];7 {. J m) b$ c: W: l0 S+ M
pOutTri->index[1] = pTri->index[1];" ^5 h+ [$ T& _$ d$ ~
pOutTri->index[2] = pTri->index[2];, m+ z. c& k: K7 C' R* z1 e
}
: q d& J5 [4 j: t: v q# D
- l9 Q2 A9 ~% A outputMesh.UnlockIndexBuffer();9 ]8 H H. n0 t
}+ R+ U a4 @- \) f8 r
) X7 z1 c4 m, e, F0 R# b' u
其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。6 D. W% {- v9 J9 u' o4 [
: m( |( m! f: n$ V1 ^# w! {6 B
z, A- _8 a! h$ G关于md5anim文件% ?7 ?* U& _9 r; _
7 f" F6 q6 i& X S$ y Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。
6 X# m; L6 J8 F$ c" f o, r& r5 w& F" ^
8 M |6 E) J* d2 l& @# ?7 K, y/ E可能的扩展, m" h& `* c) C. ?& I
x! B2 ^! b. ?$ w, e' o7 F) t- h" i
一、复杂动作的混合8 f$ |9 I3 D9 t
+ W5 k- i- @, h 有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。" L" d) s5 u- T$ r* H' J
. a9 X& E( c+ ^" K! T
二、基于物理的动画
% Q2 C0 [) E$ N5 b
O$ h1 u% j, s7 [; c 这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;
; r" K% R T$ W
$ K4 j* G4 L# Q" b三、基于GPU的蒙皮
. L! A) j3 z, F* Y( e* k
8 o6 N8 \7 O3 N/ P" n& j 原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。
, m! Q8 Y5 k0 K- E5 R- ]
+ z8 U0 ]! O! v4 U- K. J8 e% c% p四、非常流行的“换装”系统: M! Z$ O$ V$ j; r6 a. w
5 I3 N) c9 Y5 x0 q
这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。 |
|