|
我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。& I" v8 b+ j# l; k# I
& V1 y- o* w$ Q* C3 o在这篇日志里面,你可以获得这些信息:! ?1 q# R6 T* C9 n( C% x
3 d5 `0 h# m E( }* k
1 人物动画的框架1 S* Q: k3 u8 @6 W, `! |
2 骨骼动画及蒙皮技术
$ N3 [2 f! C$ ]4 u8 x' Y3 doom 3和quake 4中模型和动画格式md5及原理/ L5 y* i# C o2 |# _# Q' h
4 可能的扩展
) [' h }0 ^ C/ ^; E: H. y# w* I
" i& _1 X9 W$ I3 s
, \0 ^, E: r; ^! b) z( l) X* l/ E/ `+ v先来看一下人物动画的几种方法:
* J& t4 V0 f/ ?3 E9 d! ]% y# o! Z" v7 L" `: F2 [4 ?& M7 n- B
一、简单关键祯的动画
) O0 I# L) L: |7 e X& A
( \) z0 n( A; @ 像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。7 a* b, R0 J+ G, s2 c+ H
4 E- _& d; i: |; y$ X
二、简单的骨骼动画及蒙皮技术. C. e2 L3 R/ T) u
6 y6 V* T2 N6 R) P% g" ~ 现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。
" c' ]3 C: {7 X/ c5 q1 i6 a9 u# F; `, O
三、改进的蒙皮方法和基于物理的骨骼动画* d$ X3 x1 r( z: e/ M
. E& t7 S% Z c3 @/ N 改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;
0 y0 J6 O! T+ x; ]9 C$ |0 G+ ]3 [5 e% x3 k
基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
- h0 u1 b9 U1 h5 m ~4 Q J
7 a; k, t5 _/ o
o$ o# x8 F% }* S m, J* }
3 Y/ V: A1 e, c* `; h% K基本的蒙皮原理
. H) j2 M& e- D0 D. z9 B: T$ \5 Q7 \' x/ t
拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:: b- v/ b- l4 U4 `4 P" M
* Q( ~1 i# I( s' K( s8 x0 NJoint: 用来记录骨骼的关节的信息;
2 Q, {" O) r0 r0 Y) v2 O$ m3 cWeight: 用来记录顶点相对于关节的权值;
9 E' l) u9 }# CVertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;
# {* D( g o( ~* M+ e. P3 K6 o
4 y) E" w+ g4 X5 G" k% r6 G4 b现在就来解释一下这三者之间的关系:
9 A _$ M* g% A, @! Z
0 r9 t8 W+ y( P9 @+ s3 B0 t Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。0 k1 g7 R! R3 L* L& d. X
" o3 R7 {- }5 c
很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。
" x: F: g2 K2 I
% X7 |' w4 N! p: J 了解这些基本的概念,下面就来介绍人物动画系统的框架。
' H9 c5 F! A M; e0 }- G i- w3 Q+ s: D+ y6 Z1 I& C) B
. I9 P+ N& ^+ U2 l7 ~( x
骨骼蒙皮基本框架
% E. h) Q% O9 B1 s7 t$ J- y" c) p* G% w m% M3 l) y8 v! u6 X
基本的类型:
# B! r4 N! f$ y" f; ^( k1 `. O. K2 Q! ~
关节信息:; P X M% o3 P# b2 W+ O3 w
) B( n8 X/ ?4 ^1 y) O
typedef struct _CharJoint3 ^1 w* O9 V1 P, \1 r( f; L4 p
{! Z, ?% B) A7 e1 h i% a4 M- j* p
Vector3 pos;
, J! `3 k1 _3 m' Q5 g q Vector4 startPoint;: Z$ r( N! Q4 a* ~, N6 l
int parentID;) B9 u/ A' q6 m: U g. R" S. [
char name[32];
2 R" {0 X x8 S0 f) [* c$ _} CharJoint;& y+ B4 I. u( J# j! R6 a o: @
9 {/ T0 N. ]& o& S k) w; c
其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;
8 h3 t* z l/ P9 R( r$ e
4 d T' {2 P R$ O$ `权值信息:6 b9 ~2 {9 s* n6 {
/ V8 r! [1 k. \typedef struct _CharWeight+ }! g0 w+ H4 \5 ?4 ?( v5 \
{
9 x! t g, v2 p( c9 A Vector3 pos;/ {) p4 g9 o( @2 L* U7 z. Z, j& G
int jointID;
: p3 U+ @) I/ r float bias;
4 P9 S$ A- k* m& B, g3 h; y9 _5 g} CharWeight;
( c$ P" e a8 Q2 d! k2 W s4 g' a6 R8 l( n- l! C8 P
其中,pos为偏移量,jiontID为对应的joint,bias偏向值;
( k* b" q, Z) i; k5 l; e: M
# c: j# t6 m# b9 [( k. a. a2 A3 ]2 g
顶点信息:8 r; o: D& m& n& _5 \
4 \' ?7 H! L! V7 A M1 Htypedef struct _CharVert' @* }5 V. [1 K, U$ W
{9 S2 o$ m6 c" e! v5 ^5 e. I- b( |
float u, v;: I& L2 K/ K- z) o. J
int startWeight;
& H$ o& O+ Q/ J+ A1 R" b4 U; ~6 a int weightCount; R4 S# ~* L8 e* ?% j1 w1 u
} CharVert;+ ^& Q9 L+ p. A0 y+ a5 J* j1 |3 n
3 O1 O4 Y- ]/ X% H! _+ x
其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。' H+ P, ~. D- \% ]
8 h7 N6 x% ~5 a* n/ U
9 x+ {* c( Z+ Y1 \# G) t大概还涉及到这样一些类:: T- j' A/ F6 Q1 ?/ F) i! o3 d) Q
1 v% l( X0 b9 Z* J, NCharSkeleton: 记录整个骨骼的信息,包含了关节的链表;, c1 w! z) {, [
CharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;& H7 [) m9 D: m; W
CharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);4 b% ]2 _9 @: Y2 \+ x7 i+ @' C. R7 ?
CharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;, v7 i" h" b ^+ }5 y
CharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);
~# j1 c. N6 d6 z1 |. c/ H% u; F# j1 E; D$ }5 {+ B+ f# u4 C
% ~' [5 ~0 Y5 e$ \* i8 L( |) r解决关键问题6 d: k! j. {9 m
0 ~( `5 M. p! A% X5 @+ P, f 刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。/ v( l7 k; P3 c+ o
1 u# X# e; W1 a) |: g% B+ \7 V关键祯混合:
9 C) T$ ]# c5 ], j. s& }2 ~ U/ j( c
; Z- f+ P$ }9 ?6 q/ c, {7 \5 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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);2 e, e5 f' z0 I) _8 N0 c
E$ `' R/ e" Z 际应用中还有其他的混合形式,后面再来介绍。
+ {2 C3 c2 z" |$ ~# G" H8 v3 C' W; D* L; V3 b5 c1 {
% C8 {$ y' g0 @
计算实际顶点:
7 F8 I1 s7 z# s8 v; H; a: t7 L: W! N. w( d) @$ ~2 o3 N) e8 ?
我们看一下软件的(用CPU做蒙皮)Blend过程:8 X0 o8 [7 H& Y3 y" X ?
8 j! S* ~0 Y5 w7 A' c6 |: i
void CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )
7 a# C. A% c4 J. H2 }) d: `{/ V4 D0 Z( t" x% b. d! h* P
if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )
# u& e. _7 R( |) a return;
# Y( @/ B6 C9 s c* N5 \, c( ~8 j) Q2 |/ `+ Z8 t: F
CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();! }: p8 U; h6 i
int numVerts = inputMesh.GetNumVerts();
# @: P- b# n% z' [1 v int numTris = inputMesh.GetNumTris();2 W2 C3 ~) _0 y. i; h
) `5 N/ O& V7 X* A: {
for ( int i = 0; i < numVerts; i++ )
# Z2 Q* s( q, S. h {
6 a+ C; t6 U' m const CharVert *pVert = inputMesh.GetVertAt( i );6 Q* j6 p& Z% ^; Q- D
pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;
* G6 A m3 J# e7 p/ _) g2 O$ g" I8 U W) n
/* u v initial */
; W& `, S/ l- l pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;% z& O' B0 y9 v/ J8 n
pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;( v% K9 w8 M) l3 G _; o
, q0 I% I9 p5 F4 g9 q
for ( int j = 0; j < pVert->weightCount; j++ )# G( j1 W7 l: C" U' ^6 `- N
{
/ _" N1 H9 J# k) Y$ o4 v! y const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j ); K6 M( F2 Z7 A9 J
int index = pWeight->jointID;
" V7 K% a" q* D3 n8 C; k const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );
5 x5 |4 W! j/ @" T$ z vec3_t wv;
* M) u) i4 |+ A9 I- w( @1 B" P Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );: \& e( z* |( b- T9 }
pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;1 Z0 z/ d; l ^* ^2 X7 I
pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;
- ^: F* _2 b1 W; d pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;
4 I% E9 }0 n& M; Z. x! S1 A }
6 ~; ~+ Q" Y' M/ r8 _) V }
: E; `' e9 K% e2 G3 R
0 K7 D' p5 ^$ @/ B# b outputMesh.UnlockVertexBuffer();0 b: D' z# e2 o4 l+ c
7 w% e$ N0 `1 x2 D CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();
" |& }- K, z/ N
|/ M6 ?. O0 S2 I6 Z for ( int i = 0; i < numTris; i++ )
4 _2 t* h. ]& ~ {
5 }9 {& S3 ^" p const CharTri *pTri = inputMesh.GetTriAt( i );1 r' P2 Q2 i1 J' S
pOutTri->index[0] = pTri->index[0];$ X4 g2 F& m' j% W- `, C
pOutTri->index[1] = pTri->index[1];
1 _8 z+ D( m0 u% V* b h$ ` pOutTri->index[2] = pTri->index[2];
/ Z. |/ r0 c! I7 D9 ^* f% x }: d/ a7 `; p& W2 b! |+ h2 c( Q. Z
9 X- q g1 |9 B
outputMesh.UnlockIndexBuffer();
7 r4 m7 O1 A: |}" |9 P3 p! o+ g' O3 z5 W' }
* I; L) Y" y$ c8 Q& _" r+ n
其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。
- G* W, U" [' |1 k" v) g" t: ^$ i' Y/ }) y/ k0 A
4 w1 |/ p5 s' n6 e S, S关于md5anim文件
( O- e3 S8 Q8 w' g
( D' r" z6 u& Z# |% G Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。- r' e c/ S" \6 d5 x6 x/ V4 m+ {/ j
! h. B: U$ ^# K. L' x0 c3 x/ H0 J3 n0 t" e6 t& T+ M/ \
可能的扩展
- x& L# f- `* o% J7 c f, {6 A, }) e6 k5 ^* r6 V' Z: a; x
一、复杂动作的混合
* p7 ^2 T' T5 E2 P! h4 z3 k6 L
' J' q1 C$ z! u3 F 有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。& P' t" l+ k) k' W$ S `* e
2 V s0 F! g! Y! o7 b6 r3 F" W
二、基于物理的动画9 M% S/ k3 b3 t
7 j1 t* Y9 e- m 这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;
8 e9 v2 \# `6 }+ t: @% Y$ S) g( [! Q, d9 J7 x" ^* C7 K3 y: w) _
三、基于GPU的蒙皮
$ f+ y6 R# a I" m( V# X9 \" s$ `7 D, v2 U6 C: b' q0 v8 x3 o
原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。' M2 O+ ~8 x# I# j
) q7 M' z/ c( c6 o
四、非常流行的“换装”系统
! f# c6 d w7 e
- I3 h9 B! V: r+ k8 M 这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。 |
|