|
|
我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。
$ y8 R l4 k2 ]/ {" E0 j/ y s) w9 V; I# g0 P2 l
在这篇日志里面,你可以获得这些信息:- a+ V5 r* u) ]% A. d7 }, B
+ a" n. g' [/ p
1 人物动画的框架
0 x( W2 e7 G" _- s/ K+ Y2 骨骼动画及蒙皮技术
9 P1 U! \) P6 i8 }# W: e- m3 doom 3和quake 4中模型和动画格式md5及原理
$ f5 t0 m( g7 j& J% V4 可能的扩展$ `8 n& q5 G' E* f* P7 c' y
1 W; d% |5 F8 }' k! j" a
/ {* x0 S6 Y" n2 `( Q先来看一下人物动画的几种方法:% _- G+ g8 D& X9 n+ b3 e
: P1 }1 y1 ]/ L- `8 r一、简单关键祯的动画; C5 ~0 l, U. Y, ~- K
% w7 a8 `2 V( L 像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。& [. ]! ^4 r9 R6 T$ A& y0 z
8 q! [5 F* U4 S+ [- K# J二、简单的骨骼动画及蒙皮技术
( V7 |1 G7 U% h1 ?
( F; r2 L* x4 m. b- S- w/ k 现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。" D: [8 s+ f% `' B
! S4 Y1 y: I" Z. g. J: A三、改进的蒙皮方法和基于物理的骨骼动画9 ]# E; n9 M8 l b3 K
0 A. S6 n" }: w7 |9 Q) [. e+ K) H
改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;: H5 N0 A! G& E$ t% M& g- I
( ~0 U/ T' a! b 基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。
3 V1 X# k, i$ u+ F1 Z0 }8 t. }4 N+ I! u
( d/ z& T }" B" f+ g
8 P5 T0 u- Q$ d( K' f E, J基本的蒙皮原理2 ]5 R) j8 n+ V' m* [* K. [
+ B& r/ U, f: L0 q% d0 D) z2 ^3 V
拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:
/ m9 ^- x+ Z# n" }4 [$ K6 |* |3 W4 L$ _/ h1 r$ o, [
Joint: 用来记录骨骼的关节的信息;$ [4 U! h7 Y6 O
Weight: 用来记录顶点相对于关节的权值;+ ]6 h" h6 d F. @: Q
Vertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;/ Q, j. d" t0 K
- U+ x9 q" i& ?) _' }! \0 A现在就来解释一下这三者之间的关系:2 W( Q$ H' w0 g: n' X
. J7 ]2 o# D! X3 C9 \: H
Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。
+ g8 c3 ~* V, S9 z7 @% X/ j5 O
- D& p6 P6 A9 }) m; [0 W 很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。
! R: |1 G0 U! q$ H: e. i
: A2 j' `1 K: u+ V 了解这些基本的概念,下面就来介绍人物动画系统的框架。
' a/ Z3 X! r9 x' G z/ H$ a& ~
5 @1 Y2 ?* x! i5 s" F; a; e8 n p/ G* s8 @6 E8 d! b
骨骼蒙皮基本框架" C2 u9 h* o7 h+ |
- x N a: f8 ~基本的类型:
, h, y! A& \: ?; r8 t
4 ]% X* Q9 p# q) H关节信息:
6 ~. V% O+ g. y5 ~. m: I$ g
4 {4 w' P3 ^: l! z. Vtypedef struct _CharJoint
. ~' e8 o' G0 }. C' _+ t{
6 A% t& V; B% G" C3 L Vector3 pos;
8 ]+ p3 d& j& H$ _4 {) C& j Vector4 startPoint;
# E$ C# x5 l% E' j int parentID;' `4 ~: E0 |0 A r
char name[32];' i5 \- H" U* ~1 ?3 V
} CharJoint;
6 a) o: b6 h3 E) l! E; v6 p% M! h3 X' S8 g
其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;
! U$ t1 @1 y$ S- G. ?
; I. i% E7 @+ j& r8 w! G+ k权值信息:0 Z4 r- ?' n) `: A- U" d1 _6 C
7 p- y6 a* `7 T
typedef struct _CharWeight
$ \- |( e$ O0 F5 Y4 l. [{
, Q# _! o1 O& @: I k Vector3 pos;
/ {) i7 y/ L% s( n% U int jointID;2 G; N; x3 m9 G. \! _
float bias;
r0 t: D$ I/ _. n} CharWeight;6 j1 e1 {4 d# A9 @5 c
+ k1 K, I3 f9 p3 T& f
其中,pos为偏移量,jiontID为对应的joint,bias偏向值;4 n9 i( h& H6 @4 m
# g9 c# I5 b3 I0 Z, G/ B3 R
+ X9 x) H/ A% r- U顶点信息:
8 ]( s( Z9 X0 y- V, U2 i, c6 W2 V2 H7 Q
typedef struct _CharVert
& p$ D! c8 z. d& f* \" A# D{
& n7 B% n+ y3 T" Z! J$ b. K$ X8 `" Y float u, v;
' W& j. h' K9 @ int startWeight;/ |7 ?5 I* P( i9 g
int weightCount;( f' D+ l: y" ^ ~4 F, S
} CharVert;$ M9 x1 Z# O+ {2 y1 |' ?- r
& v+ O: N& ]9 u5 ^3 z
其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。
2 e- v" ~* |. y
+ ]3 d) K* _3 s+ `3 r
7 K- f9 x+ h0 E' J1 J! ]# R大概还涉及到这样一些类:; ~5 I1 [- n8 B- G; Q
6 M7 E* d' v5 W) T$ Y' V- I
CharSkeleton: 记录整个骨骼的信息,包含了关节的链表;5 m; j% l% P# M
CharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;; ?/ A& T' x- ]6 w
CharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);# P' s. I* x4 `( U
CharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;5 P. Y" Q5 N$ z4 T% h/ i8 J5 g2 u
CharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);- \, ?; a/ r4 F! |1 e
. G0 U, O* ^5 ^" s4 |. H1 G
- T# t. h6 T% a3 e解决关键问题
- b! ?' r: u% E! U9 b: K
9 S5 D& T$ a1 ^0 k1 a! }/ o. Z 刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。1 X7 N, |6 A1 W8 W; Q
% s! L6 |& e- F$ m- o: Z
关键祯混合:6 X9 _' ]# c" @5 ?4 R' @
' b* g1 a g" K( X 简单的办法,就是直接用线性的方法混合,比如现在的动画时间标识为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 I- |1 b$ E1 ^7 |' I' M: k: C$ s8 c8 e
际应用中还有其他的混合形式,后面再来介绍。7 E' v; b( P" A
8 \2 Z, n( }+ s, v, x
2 [3 S2 m7 A$ F9 J* l计算实际顶点:
4 G( Y9 e* T7 a5 X+ ?+ I6 x$ }
4 Q1 w; Q# ~* ^我们看一下软件的(用CPU做蒙皮)Blend过程:
& M6 ~! t$ b6 C- J6 Q: h* }- G5 C# r: h
void CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )
9 G- R- V7 T3 U( v: @, M% Z{7 @/ P6 ^" _* J$ |
if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )5 C* Y- K1 p) u3 f5 R" l1 M7 P
return;
& J( `/ B; s: G. f% t# A
8 }: E! u. _+ {5 o CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();3 \% n% t ^# f% r* C
int numVerts = inputMesh.GetNumVerts();
6 W, }' r0 k9 A int numTris = inputMesh.GetNumTris();/ X: ^! l$ h; M% k: L7 g4 F8 W
. Z' n, ~ P3 K) _( V9 H) k# p for ( int i = 0; i < numVerts; i++ ): B& l9 \' v: y5 K& B2 U
{
7 n& f/ k* F; n5 Y const CharVert *pVert = inputMesh.GetVertAt( i );" w# M0 | P6 O- @
pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;: ?4 V5 l+ J0 C2 a6 p$ m6 g
8 \9 H" z# {/ D3 k3 ^- U /* u v initial */
& b6 y# l. V; z& T pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;
5 S8 w1 a" p* b! ` pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;
! k6 T! T2 b6 w% t- `. J: k1 o& x4 O% v* r
for ( int j = 0; j < pVert->weightCount; j++ )! f* O( c |$ B2 I3 U6 w2 |
{
8 J) v! Y7 Z- v- w* Q8 i const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );6 W( Y8 g3 t; T! w X
int index = pWeight->jointID;% L# R6 A4 s6 j- k% m. J
const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );
& S3 ^- n, ?1 q; N8 W vec3_t wv;5 w3 C/ [" g8 P' \% g3 R/ a
Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );
% D, y$ b2 V3 L+ a M& q pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;1 S$ r, _% E D, G6 _( x; J6 E
pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;
; t/ l9 K9 t8 I5 P5 C pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;
! x9 _9 \3 G* l8 Q, |( \' n+ J }8 K/ `: x6 D; m
}
1 j) G; `0 ] F' l0 v7 \: r+ C" y! C: t+ H
outputMesh.UnlockVertexBuffer();6 e# B* j& Y, A, B) h( t
: c* a) d N( X9 J$ j" P n CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();
. a6 ^2 @; _) q# h; O; r4 f7 o5 v5 x' z6 o- V3 Q! m& K
for ( int i = 0; i < numTris; i++ )% v l% @2 I7 l! A: x8 m/ K/ k, d
{
# d* h1 |! A4 d; D5 O const CharTri *pTri = inputMesh.GetTriAt( i );
# g) \2 T# A: k" ?! \ pOutTri->index[0] = pTri->index[0];9 q+ u. A1 L C8 h. H# x. z
pOutTri->index[1] = pTri->index[1];3 Y& v: d( |' Z m& S; H' r1 ?
pOutTri->index[2] = pTri->index[2];
2 L c5 W+ N4 ?$ Y4 ?/ n4 D" d K }) H: k9 z1 _9 U
) E# O, A8 K/ x
outputMesh.UnlockIndexBuffer();
( W0 @+ y3 W) z( D}
6 m( o; g: }, V' t; K: m3 f4 f
7 V( N `+ M! T7 n其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。# }0 L/ g+ b# r, q6 G
Q/ u) ~% M& c# G# S! z6 z
: O6 J; ?: d# ]$ x关于md5anim文件
5 ]9 {: f1 L3 @* e! N1 i7 E
% ~4 Y0 t9 w# v4 a4 R% Z0 _ S5 c Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。2 D% \+ l( ^4 V# W
3 i, {' _; C2 s' _% z
, g: J3 N+ h6 \, T+ X
可能的扩展' R3 z/ B8 ]! s; x* ~( y
: S+ @1 W2 _$ U! W& q2 J
一、复杂动作的混合
% k, A; W2 D y# n; M5 z$ k
2 ~: C/ o4 O8 _4 L+ O/ V 有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。
' |% s1 G6 H9 f0 O. f- o; E6 G7 |- {
二、基于物理的动画$ j' l x' X% v4 S; f
0 r9 U, j0 |9 j, o) V+ u6 `
这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;
6 J# }0 M" p# R7 ~8 Q1 ^$ [4 l
+ O6 ^+ |9 Q6 L) z8 I; S2 g三、基于GPU的蒙皮
7 t* e2 ?* m" }, N# O4 F: Y0 I/ J* _! i' M4 D; k5 K
原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。
% b+ P9 g7 c4 S a' c* ]( j8 K1 \% p/ d7 n6 ]+ a" V
四、非常流行的“换装”系统8 r' l ^! g% ~1 [, I. o0 z
7 i, U* g x8 E8 \3 T7 t 这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。 |
|