|
我最近在学习人物动画的方面,做个总结,由于刚刚接触这个方面,所以有什么问题请大家指出。
. V* X/ G! o! R0 B5 @$ [
/ E$ L% h0 W; D3 t2 f( o6 i3 Y在这篇日志里面,你可以获得这些信息:
u1 G( u; O0 k$ ]1 a7 C/ B
7 w" v4 O( D# Y$ M1 人物动画的框架, Z# G. P- s. E: E
2 骨骼动画及蒙皮技术& J$ u) S$ z/ _4 s# d4 K
3 doom 3和quake 4中模型和动画格式md5及原理
0 K3 ]! |, Y5 N# ?- [- Z/ x4 可能的扩展
& d$ P) ~& k/ H$ Q
" d) q% r3 b2 E
' c0 |( O& U3 d% w) m7 t0 k8 D# v先来看一下人物动画的几种方法:
; Z6 i5 O8 y9 g3 `$ x v! Y F8 c' R3 i
一、简单关键祯的动画; V3 W1 n7 h* [; [5 {6 @' x
! [9 e3 o# k- a
像quake3中就是用的这样的方法。这种方法最简单,缺点就是空间上的浪费。由于每个关键祯中都要储存整个网格的几何信息,所以用这种方法生成的动画文件相当的庞大,在游戏进行中也会占用大量的内存。现在的游戏,一般都不用了。3 O5 o! R! T) v& f6 i
" |& @2 O7 q0 E* X二、简单的骨骼动画及蒙皮技术6 @9 K+ ?3 I* P, a& J
) m7 u9 S6 }8 ?/ F ?% l% ^ 现在的很多游戏,都是用的这种方法,具体的原理后面再解释。这种方法可以节省大量的空间,对于美工来说,工作量也相对较小(可以利用动作捕捉的数据),真实性方面,简单的应用中也表现得比较好。5 }0 [1 o( Y3 W: p3 {
7 g9 N/ I0 J* l' P5 t5 z7 B
三、改进的蒙皮方法和基于物理的骨骼动画
2 W a( i- w3 D9 m s+ {4 K" H, \& }) M$ d/ E8 J% M" Z/ J
改进的蒙皮方法可以避免简单的蒙皮中产生的“糖纸失真现象”;; @- Y8 F, j6 O$ N) g2 g/ w
% t3 L& u' b5 ] 基于物理的骨骼动画,已经有很多的游戏、物理引擎支持这一特性了,但是还有很多的技术问题需要处理。这是次时代游戏引擎必须很好实现技术之一。1 s* o) ?* `' A9 r
2 b+ I [4 P: }' @1 \8 Y* Y
% ]! G9 t' c% M* l/ G/ U8 h1 O0 @1 N# X* k, i/ u" [& c% L
基本的蒙皮原理
9 d2 m8 N/ T2 J3 L0 M: r1 U1 P4 N1 ^3 @* f: U( \
拿md5格式为例,来简单的解释一下蒙皮的原理。在doom3和quake4中md5mesh文件,用来记录一个人物的静态模型。有这样几个结构:: ~. r4 n% b% j" R( i/ v
- V C H5 W6 e8 Y
Joint: 用来记录骨骼的关节的信息;1 w' r% \6 r0 h7 l
Weight: 用来记录顶点相对于关节的权值;
6 E6 M. l% p3 X" e& q' J v1 FVertex: 顶点信息,和一般的顶点不同,这里的顶点不直接的包含几何坐标信息,而是记录了对应的Weight;# y# v! H7 ], w8 C& [# c
5 U) g% f5 H% T+ \" d
现在就来解释一下这三者之间的关系:
# R$ k# G$ N, W: h# r) m; g* W' ~- ]1 `# T7 ]9 v& V
Joint(关节)是会动的,而皮肤上的顶点是会随着顶点做相应的运动。我们保持皮肤上面的各个顶点和它相对应的关节的“位置关系”,就可以通过旋转关节,使得真个皮肤跟着旋转。这个“位置关系”,就是Weight。在运动的过程中,我们获得当前的骨骼的几何信息,也就是每个关节的几何位置,然后在根据每个顶点对于这些关节的权值,分别计算每个顶点的实际几何位置,这样,整个人物网格就计算出来了。7 W: v7 L" l7 A0 U' { P
! d5 C- D9 b# ]4 b
很显然,这其中有一个预处理过程和两个关键的步骤。预处理就是需要由静态的模型(美工做出的人物模型)和骨骼来计算得到一组Weight;两个关键的步骤是,1、获得的整个骨骼的几何信息(有可能从关键祯混合得到);2、由顶点对应的Weight来计算出每个顶点的实际几何信息。# C8 b& W3 {" d
* {( q1 Y. k5 _. J2 m0 v 了解这些基本的概念,下面就来介绍人物动画系统的框架。
' Q( d u, p/ A" s) |6 v+ w! ?0 i9 c3 [
" |9 @. t a1 U- [: H) n骨骼蒙皮基本框架
+ q$ @. [$ o5 T& v2 q3 s L
) {& T' u' s# T4 q0 y' e+ `基本的类型:/ \# o' c, o3 ` T. ]/ L
$ ~: x. q: ?; {* {8 Q9 o关节信息:
! [( p: X; [- K/ |& Y5 g2 `3 u( |6 X7 T4 c% O4 t, q
typedef struct _CharJoint
" r( a. J. p! g6 I { l6 R# N8 `{1 B$ ~8 ~: b$ @4 a4 i0 c( g
Vector3 pos;
) h. {8 u3 F4 j- p. B/ | Vector4 startPoint;0 R7 j8 ?) K; Q6 x
int parentID;
6 ]' k1 D1 z4 ^( N- j3 T char name[32];! n8 s$ J; c/ l6 ]% T6 ?
} CharJoint;( [; h. B) o- ?9 l# p* N
: n4 Q* g7 x! ^1 X
其中,parentID为父关节,pos为对应父关节的偏移值,startPoint为旋转角度;
1 g* {) |" R% b" \& u% U+ A' ]
% B5 V7 V8 \( ]; s2 u4 N权值信息:
* Z) Q2 ]6 f% Q, y; F' E1 V; u& b
" m/ j' t2 [9 W B" H0 e( o' u' ]# btypedef struct _CharWeight
' ~; q! y9 t4 R) {1 d; h) z{
9 s0 p( R) d) ]. M Vector3 pos;2 g, I4 d$ x+ K2 _5 w2 S
int jointID;3 s+ m+ y. e' G+ |3 B9 m$ G/ ~
float bias;9 h2 ~9 [3 @3 f3 |6 p- L& x/ M3 ]1 u
} CharWeight;$ V, P% B2 J0 _) w
9 K- k1 Z( R6 w/ C" n) P& Z4 [
其中,pos为偏移量,jiontID为对应的joint,bias偏向值;
6 h" ^5 v6 b" R% P. i' S
: L7 w" a# q# u* h/ O9 s( U* {
顶点信息:
^8 t7 n( t' h! {; c% b. U/ v% q$ E& m
typedef struct _CharVert
2 m6 i- @5 R' }) i: A{
2 C$ M* y0 Y; }& a" p float u, v;
; g- ?+ g5 e& B3 | int startWeight;
: y1 ]1 c: j% B. T( A# ^% d$ j int weightCount;
; X* ~+ W B0 {% h, J5 {, ^0 I} CharVert;8 `& V* U" b7 f1 c, N# s
- C$ |" f& Q1 w( t3 b. i$ p
其中,startWeight为该顶点对应的Weight在Weight列表中的偏移地址,weightCount记录该顶点对应多少个权值;对于简单的顶点,比如头顶上的某个点,动画的时候涉及到的变化并不多,所以,对应的权值数也就少,可以只有一个;对于动画中涉及变化比较复杂的点,比如手肘区域的顶点,可能由较多的权值(4个或更多),这样才能够很好的表示运动中对于多个关节的相对位置。7 A( a: P) M l2 A! H9 I
0 c; `& M( @# |5 Q
. a. g& }. }6 O( S4 r
大概还涉及到这样一些类:
' Z& K9 E0 B4 A' B& C0 L$ c* Y' n; I2 E% _6 i; S% H
CharSkeleton: 记录整个骨骼的信息,包含了关节的链表;
6 y" @; c; h& q+ y3 Z! N0 Z( WCharMesh: 记录整个人物模型的静态信息,包括顶点,权值,关节等;
' k2 E, c( x) @+ _CharBlender: 基类,根据CharMesh和CharSkeleton来计算出实际的网格,基本成员函数为Blender,用CPU来计算蒙皮,可以被子类Blend覆盖(比如可以写一个用Vertex Shader实现的Blender);! R( z h" }7 @" R
CharAnimation: 每个CharAnimation实例对应一个动作序列,比如“人物蹲下动作”;动作序列保存的是人物骨骼动画的关键祯,也就是在某一祯时,骨骼中各个关节的几何信息;注意这里的祯的概念并不是平常说的渲染的祯,在动画中,为了进一步节省空间,一般设定了一个动作为几个格,就像动漫制作过程中的“故事板”,只是整个过程中的几个缩略图,在后期制作过程中,在“填满”中间缺省的图片;这里的骨骼动画关键祯也是如此,文件中只保存了间断的几个状态,在渲染的时候,还是要实时的生成中间的某个状态,来把整个动作序列“填满”;* w6 K' L7 j k4 j9 E
CharAnimCtrl: 这个类的作用就是完成上面所说的,将动作序列“填满”的功能,输入是CharAnimation和时间,输出是一个基本的骨架,也就是CharSkeleton(当然这是靠传引用参数进行输出);
* }4 w, w% R' c2 {
9 w( D1 @& c! k9 B( h3 M. z/ _2 H, H W+ d& E+ ?' `9 b% S4 J
解决关键问题
4 ^" }6 c8 T- \9 h
# [) }. |6 q3 m! A y! @ 刚才提到了,整个系统中由三个关键的问题:一个预处理过程,关键祯混合以及从权值计算出实际顶点。预处理过程,基本上是编写一个建模工具导出插件的工作,这里就不讨论了。
# F. U3 h# M, v* c( C" c8 K& J* U" t( P" K f* }* }4 S$ }+ ~
关键祯混合:( H1 N3 x4 p5 `( P& p8 K
" J8 a5 g. ?. j9 v2 C" i; g, 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的骨骼。恩,就这么简单。(不过注意不要把这个比值的含义搞反了);
) J S: z+ w/ h0 V. C7 p' K0 o5 j/ I8 G- \, F/ V! E# ^3 Y) t
际应用中还有其他的混合形式,后面再来介绍。+ k2 } s- d4 g
) _4 {; \. b' q5 A8 ^
5 Y# w: i# q) Q9 G4 O4 ~计算实际顶点:$ k; j5 b- W! r0 Z, @0 A- v
% `6 }& z3 {: i( {0 O' F# z( ]5 Z
我们看一下软件的(用CPU做蒙皮)Blend过程:
! A6 [2 `; C9 M0 ?5 j" d4 \& Z9 A, ~
void CharBlender::Blend( Mesh &outputMesh, PE::CharMesh &inputMesh, PE::CharSkeleton &inputSk )
6 f* E6 k- P% a/ T$ V{
6 v, H: g: j( E" A! R( T; B if ( outputMesh.GetNumVertices() < inputMesh.GetNumVerts() )
! j" | [/ J) t return;) n. d4 _3 n) k3 ~
4 j9 t9 i8 I7 s6 b' N: J$ h( W
CharOutVert *pOutVerts = ( CharOutVert* )outputMesh.LockVertexBuffer();* {8 E1 {9 }7 }. s' q
int numVerts = inputMesh.GetNumVerts();
5 _# Y3 v* j! q: m* X; Y int numTris = inputMesh.GetNumTris();4 a, K. M, G7 V) a# K
2 B4 R3 d/ i& S( k5 m0 N
for ( int i = 0; i < numVerts; i++ )6 x F* d1 y# G; l4 W2 K) B: R4 b
{/ i4 s- X2 v% a4 Y
const CharVert *pVert = inputMesh.GetVertAt( i );
; J c2 J0 R3 o& v pOutVerts->x = pOutVerts->y = pOutVerts->z = 0.0f;( j0 \5 u" f/ v+ U2 B0 s \) f
_4 ]- @3 L, a- }! [+ B a8 ] /* u v initial */
) A& s3 }, z% Z3 \2 F pOutVerts->u0 = pOutVerts->u1 = pOutVerts->u2 = pVert->u;: A( x; I6 D' J( e
pOutVerts->v0 = pOutVerts->v1 = pOutVerts->v2 = pVert->v;
) W5 a& B2 @2 O& [# n4 z* S
" i/ w& I4 h: Z6 F/ j$ N2 j- _ for ( int j = 0; j < pVert->weightCount; j++ )
0 W0 b, t* ~0 A' q: v {* f6 O* i6 t% ?1 C7 w5 Z' C) c) N1 I
const CharWeight *pWeight = inputMesh.GetWeightAt( pVert->startWeight + j );6 Q3 q5 ~( V3 s8 ^) O; c% i
int index = pWeight->jointID;8 _5 k( B0 @3 Q0 z% v
const CharJoint *pJoint = & ( inputSk.GetJointAt( pWeight->jointID ) );: o, d+ S' r8 O" J: F
vec3_t wv;
. N& s( j+ g- h; X Quat_rotatePoint( &pJoint->startPoint.x, &pWeight->pos.x, wv );
$ D3 N: u7 N | pOutVerts->x += ( pJoint->pos[0] + wv[0] ) * pWeight->bias;- U1 {- a) X2 f0 D* C; ^1 E7 N$ |
pOutVerts->y += ( pJoint->pos[1] + wv[1] ) * pWeight->bias;
4 B3 }9 {' g7 j* S x8 F6 E" o# L pOutVerts->z += ( pJoint->pos[2] + wv[2] ) * pWeight->bias;( a7 Z/ {- D) K. a( q( j
}
8 j0 {& j3 w/ k; H1 P3 E& G* F }
+ c6 L* Y' {; q) b, o9 ~4 G( S" A6 E# G% I0 x/ |
outputMesh.UnlockVertexBuffer();% }* G1 `! B# {: H0 ^+ Z* ]
9 r4 e# Z3 F+ U. b6 W7 ] CharTri *pOutTri = ( CharTri* )outputMesh.LockIndexBuffer();
6 m+ Y1 D6 u& \
0 B2 G2 \1 F/ h; d: e" d4 ~+ }, s% b9 i$ D( L for ( int i = 0; i < numTris; i++ ). T. F# J* j9 }6 R* ?) a
{3 I; i) \7 V+ n
const CharTri *pTri = inputMesh.GetTriAt( i );1 q9 P. k( f4 n7 E
pOutTri->index[0] = pTri->index[0];
) g' V z+ Y& \2 Q* | pOutTri->index[1] = pTri->index[1];, x2 G' }& _/ T4 q6 {! W' w
pOutTri->index[2] = pTri->index[2];$ k3 t6 v' I# ?4 h6 B
}
1 q1 V" f( |/ g) C3 r9 ~ f
& c& Q- @' _4 z& K8 h& _1 } outputMesh.UnlockIndexBuffer();! I: R2 c6 `& V5 d8 I8 Y
}. ]. H* V4 B ]! ]
- i" z# z/ u5 H# B1 Q" _
其中黑体的部分,就是关键的代码,应该很容易看懂。其中,Quat_rotatePoint函数的作用就是将点进行旋转,得到新的坐标。8 r; ~. H5 A# w+ Y8 u0 A5 e; f
: q* m+ @5 t5 \! {. P
) M0 f' S: U0 P4 \- \
关于md5anim文件, G( P; L) y4 a: [
/ x; W/ M- I9 W* z
Doom3和Quake4中的动画文件都是用md5anim文件保存的。md5anim文件只含有该动作所涉及到的骨骼关节的动画信息。也就是所,文件中关键祯的关节列表,是它所对应的md5mesh文件中基本关节列表的一个子集;这样做当然是有道理的,因为,有些动作,可能只涉及到身体的一个部分,比如眨眼,换弹夹等等,那么,把一个完整的骨骼框架放在mesh文件中,把若干不同的局部或者整体的关节序列放在不同的动画文件中,这样,可以最大限度的节省空间。$ l6 I9 N/ v" O9 ~% j
4 Z# k# a$ S% Y; @
( N' G6 J# R; B; i0 }! d可能的扩展 n. @ Z/ J! M3 _, u3 j
; e7 X* i+ r6 t& _. \9 }) c3 V
一、复杂动作的混合$ u2 L& J0 b- `* s; G, a) l: g
" O' F$ D6 m1 O4 A* c6 |2 @8 J 有时候,我们需要将两个动作混合,比如,一个人物同时的在做两种动作,一边向左平移,一边向右方开枪;不可能为每种可能的混合动作做大量的美工工作,而且空间上,我们也不允许这样做;可行的办法是,混合两个不同的动作序列,比如上半身动作和下半身动作的混合,这当然是最简单的方式。还有很多比较麻烦的混合方式,比如,人物在行走时中了枪,需要混合“行走”和“中枪”两个动作,而简单的线性混合是无法真实模拟的。
- m) }- T( N: @( }4 e C8 ~, y" N, `5 [! O" l
二、基于物理的动画* [% ?# j' e- b+ i6 f" e" P9 e
2 |) ^7 K" ?' s1 Y5 e" c( F 这不再仅是图形方面的问题了,这其中涉及到了大量的物理模型,这个,我也不懂。。。可以从第三方的物理引擎获得帮助,ODE好像就支持了;/ }, \2 ^, ?6 v8 M; R
. C& A, c8 t$ c n! L三、基于GPU的蒙皮
3 B0 P& p c. j. ]% Z- `0 G+ y6 n/ b, _$ f& {" ]4 X, l0 x$ \# q
原理和CPU蒙皮的原理一致,只是用了Shader,会比CPU蒙皮的效率快很多。在前面的代码中,只需实现CharBlender的子类就可以了。
% w6 Z; ^9 b# ~* s0 N t$ ]5 v- l) J2 V4 R# T7 U
四、非常流行的“换装”系统, u I. ]1 @; ]; B9 [% b# p: G' H
: g% L: f) b3 Z/ `. n* f2 k3 ] 这在RPG游戏里面简直就是不可少的一条。就现在的框架来说,还不能达到随意“换装”的要求。修改CharMesh以及Character的底层,需要能够添加和删除基本的骨架,支持多层皮肤(衣服)(多个Mesh的开关)。还可以更换不同的武器(底层实现还是通过添加骨架完成)。。。 |
|