引擎設計跟蹤(九.14.2b) 骨骼動畫基本完成


首先貼一個介紹max的sdk和骨骼動畫的文章, 雖然很早的文章, 但是很有用, 感謝前輩們的貢獻:

3Ds MAX骨骼動畫導出插件編寫

 

1.Dual Quaternion

關於Dual Quaternion, 這里不做太詳細的介紹了,貼出來幾個鏈接吧:

http://en.wikipedia.org/wiki/Dual_quaternion

http://www.seas.upenn.edu/~ladislav/kavan08geometric/kavan08geometric.pdf

http://www.xbdev.net/misc_demos/demos/dual_quaternions_beyond/paper.pdf

Qual Quaternion可以用兩個quaternion以類似二元數的形式表示( dq = p + ε q, ε^2 = 0), 其中,實部用來表示旋轉, 虛部可以解出來偏移量. 一個dq可以表示一個不帶縮放的剛體變換:

1 DualQuaternion(const Quaternion& rotation, const Vector3& translation)
2 {
3     p = rotation;
4 q = reinterpret_cast<Quaternion&>(Vector4(translation,0))*rotation*0.5;
5 }

需要注意的要點是, Dual Quaternion的插值和混合, 也跟quaternion的插值比較類似.

quaternion的一般線性差值不是勻速平滑的, 如果要精確差值的話, 需要用球面線性插值, 但是在變化量比較小的時候, 可以用線性插值作為近似值,不過需要normalize, 即quaternion的nlerp.

與quaternion類似, Dual Quaternion的線性混合(DLB, Dual-quaternion Linear Blending)也可以用在差量比較小的混合, 作為一個近似值. DLB跟nlerp很類似:

(w0*dq0 + w1*dq1 + ... + wn*dqn..) / | w0*dq0 + w1*dq1 + ... + wn*dqn| (w0+w1+...+wn=1表示權重), 即加權混合后單位化.

而與球面線性插值類似, Dual Quaternion也有平滑精確的插值混合方式, 叫做ScLerp, 兩個DQ的ScLerp插值可以推廣為n個DQ的一般形式, 具體公式我也記不清了, 記得是用次方? 不說了, 內容全在上面那兩個paper里.

 

在shader里為了效率的考量, 使用DLB來混合骨骼的變換.

 

2.骨骼空間的選擇

這個在開始貼的那篇文章里面已經提到了. 這里也談談自己的理解.

導出骨骼動畫時, 可以導出兩種空間:

  • 世界空間(即max里單獨模型的世界空間, 在游戲里面將會變成整個角色的局部空間)
  • 局部空間(相對於父骨骼的變換)

對於世界空間的變換, 相當於預計算了每一幀中的骨骼層級關系, 運行時的計算時相對簡單, 每個骨頭相當於孤立的控制點, 不需要記錄骨頭的父子關系, 直接把骨頭的變換應用到頂點即可.

而導出局部空間變換時, 首先要像更新場景圖那樣, 從根節點更新到葉子節點, 計算骨頭的變換. 更新完以后, 再把最后的骨骼變換應用到頂點.

 

局部空間骨骼變換的好處是可以方便的進行動畫混合. 因為混合的時候父節點位置會改變, 從而影響到子節點. 但是用世界空間變換, 混合動畫時, 父節點混合后的變換無法影響到子節點, 所以會有問題.

而對於之前預研過的骨骼變換公式:

 

這個公式中的matrix[i]是骨頭的世界空間變換.

可以看出, 世界空間的變換, 效率很高, 甚至可以不要單獨保存TPose? 因為從公式上看 matrix[i]*matrix[i]bindpose-1是可以合並到每一幀里,預計算的.

 

說道T Pose, 因為頂點相對於骨頭的位置, 是有一個固定值的, 比如皮膚到骨骼有段距離, 這段距離通常不會跟隨骨骼的旋轉/移動而改變. 如果把這個初始相對位置"直接"保存的話, 那么對於每個影響到該頂點的骨頭都需要保存一個相對的初始位置, 而且, 一個骨頭也可能影響到多個頂點, 總的來說數據量會多一點.

所以取一個骨骼動畫的初始"頂點"位置(Vbindpose), 作為一個mesh保存, 加上對應的骨骼的初始變換狀態:matrix[i]bindpose,一並保存. 這個初始狀態就是Binding Pose, 也叫T Pose("T"字形).T pose還會盡量把無關的骨頭分的更遠, 避免骨頭間的相互影響, 方便美術建模.

Vbindpose是初始頂點位置, 是模型的世界空間,

matrix[i]bindpose是世界空間的初始骨骼變換,

這兩個值"間接"保存了頂點相對於骨頭的初始位置, 即 matrix[i]bindpose-1*Vbindpose, 有了這個相對位置, 再應用上每一幀動畫里的骨骼變換, 頂點就會跟着骨頭做變換了.

 

3.導出時遇到的一些問題

去掉骨骼縮放:

因為我這里的骨骼動畫不處理縮放的情況, 而有的骨頭帶縮放, debug時矩陣的數據非常小, 為了避免產生不必要的誤差, 去掉骨骼變換的縮放.

1         GMatrix tm = node->GetWorldTM(0);
2         
3         //drop scale
4         Matrix3 m3 = tm.ExtractMatrix3();
5         m3.NoScale();
6         tm = m3;

 

max sdk的問題:

一開始使用INode取出骨骼變換, 結果不對, 因為INode使用的是Max的坐標系, 而我用的是自己的坐標系, 而且已經通過IGame設置好了, 所以正確的做法是用IGameNode來獲取GMatrix變換, 而不適用INode的Matrix3.

另外, Gmaxtrix轉換到Matrix3之后的坐標系也不一樣:

1         //!Extract a Matrix3 from the GMatrix
2         /*!This is for backward compatibility.  This is only of use if you use 3ds Max as a coordinate system, other wise
3         standard 3ds Max algebra might not be correct for your format.
4         \returns A 3ds Max Matrix3 form of the GMatrix
5         */
6         IGAMEEXPORT Matrix3 ExtractMatrix3()const ;

最大的坑是, GMatrix解出的quaternion的坐標系也是max的坐標系... 但是不像上面那樣有清楚的文檔注釋, 害得被坑了好久.

1         //! Return a essential rotation transformation
2         IGAMEEXPORT Quat Rotation () const;

 

最大的問題是調試:

因為導出插件需要調試, 而且要用runtime驗證結果, 但是runtime也沒寫好, 也在調試中(-_-!), 所以最終結果渲染不對的時候, 不知道是runtime代碼有問題, 還是導出的時候出了問題.

這個沒有很好的辦法, 只能慢慢看代碼, 單步調了.不過有一些方法還是能夠幫助定位問題的:

  • 從渲染結果里面能看出很多東西, 比如動畫的根節點是橫着放的(其他骨骼也不對,而且整體是亂的), 說明旋轉有問題, 而且極有可能是導出坐標系的問題.
  • 還有就是, 先改用用最簡單的, 只導出骨骼的世界坐標, 並且可以去掉插值, 這樣runtime也更簡單: 簡化調試, 等到結果正確了, 再提高復雜度, 繼續調試, 將問題逐個擊破(調試中的分治法?).
  • 另外就是一開始先導出簡單的模型測試, 這樣可以暫時忽略掉其他復雜情況, 針對簡單模型測試, 然后在測試復雜的模型.

 

最后快完成的時候, 遇到一個法線閃爍的問題, 也折騰了好久. 當去掉法線貼圖之后就對了, 於是問題也能找到了: TBN quaternion的w保存的是鏡像. 當這個quaterion被骨骼動畫旋轉的時候, w的符號可能會被改變.

所以要預先保存下這個鏡像符號. 問題看起來確實很簡單, 但是實際中有時候要定位到還是需要格外仔細小心. 下面是shader代碼(line 28):

 1 void MeshVSMain(    
 2     float4 pos        : POSITION,
 3     float4 tbn_quat    : NORMAL0,        //ubyte4-n compressed quaternion
 4     float4 uv        : TEXCOORD0,
 5 #if defined(ENABLE_SKIN)
 6     uint4 bones        : BLENDINDICES0,
 7     float4 weights    : BLENDWEIGHT0,
 8 #endif
 9 
10     uniform float4x4 wvp_matrix,
11     uniform float4x4 world_matrix,
12     
13     out    float4 outPos : POSITION,
14     out    float4 outUV  : TEXCOORD0,
15     out float4 outWorldPos : TEXCOORD1,
16 
17 #if defined(ENABLE_NORMAL_MAP)
18     out float3 Tangent : TEXCOORD2,
19     out float3 BiTangent : TEXCOORD3,
20     out float3 Normal : TEXCOORD4
21 #else
22     out float3 outWorldNormal : TEXCOORD2
23 #endif
24     )
25 {
26     tbn_quat = expand_vector(tbn_quat);
27     //tbn_quat = normalize(tbn_quat);
28     float w = sign(tbn_quat.w);    //store sign before transform TBN, or w MAY CHANGE after skinning!
29 #if defined(ENABLE_SKIN)
30     skin_vertex_tbn_weight4(pos.xyz, tbn_quat, bones, weights);
31     //pos.xyz = skin_vertex_weight4(pos.xyz, bones, weights);
32 #endif
33     outPos = mul(pos, wvp_matrix);
34     outUV = uv;
35     outWorldPos = mul(pos,world_matrix);
36 
37 #if defined(ENABLE_NORMAL_MAP)
38     //because the quaternion's interpolation is not linear (it is spherical linear interpolation)
39     //we need to extract the normal, tangent vector before pass to pixel shader.
40 
41     //normal map: extract tbn
42     Tangent        = qmul( tbn_quat, float3(1,0,0) );
43     Normal        = qmul( tbn_quat, float3(0,0,1) );
44     
45     //tangent space to world space
46     //note: world_matrix MUST only have uniform scale, or we have to use senmatic T(M-1)
47     Tangent = normalize( mul(Tangent, (float3x3)world_matrix) );
48     Normal = normalize( mul(Normal, (float3x3)world_matrix) );
49     BiTangent = normalize( cross(Normal, Tangent) ) * w;
50 #else
51     //vertex normal
52     //tangent space normal (0,0,1) to object space normal
53     outWorldNormal = qmul( tbn_quat, float3(0,0,1) );
54     //then to world space
55     outWorldNormal = mul(outWorldNormal, (float3x3)world_matrix);
56 #endif
57 }

 

 另外還遇到了C++里, 繼承多個"空父類"時, MSVC的Empty Base Class Optimization失效的問題, 這個在我的C++博客:
http://hi.baidu.com/crazii_chn/item/5557deb54846b6f162388e30
原因是Empty Base Class Optimization在C++11之前都不是標准要求必須的, 所以編譯器可以隨便搞, 這里只能繞過去了.

 

4.動畫的混合

 使用動畫樹(animation blending tree), 暫時只寫了接口和簡單實現, 還沒有使用和測試.

 

5.數據的優化

  • 數據格式的選擇: 目前位置使用的是Float16, 只保存xyz, 旋轉使用的是Int16N, 精度應該滿足需求, 如果不滿足可以改為Float32, 而單位化的quaternion的w分量可以由xyz求得, 所以也只保存xyz:
         typedef struct BoneTransformFormat : public TempAllocatable
         {
             int16    rotation_i16x3n[3];
             fp16    position_f16x3[3];
             fp32    time_pos;
             //uint32 frame_id;
         }BT_FMT;
         BSTATIC_ASSERT(sizeof(BT_FMT) == 16, "size/alignment error!");

    可以看出, 目前單個骨骼的一個關鍵幀大小16字節. 這個數據只是加載/保存的中間/臨時數據, 它會在加載時直接轉為Dual Quaternion.

  • 關鍵幀定義里面, 去掉無用的數據.
    比如frame id, 一開始設計的時候加的, 后來發現沒什么用處, 除了調試的時候拿來校驗, 所以后來去掉了. 在動畫非常多, 幀數非常多的時候, 因為基數很大, 所以減掉一個int也能省非常大的空間.
  • 去掉冗余的關鍵幀, 思路和參考在千里馬干大大的博客, 很早在Azure的博客里也看到過, 后來地址失效了:
    http://www.cnblogs.com/oiramario/archive/2010/12/22/1914120.html
    原理文中有說明, 前面文章里我也記錄過. 比如有A,B,C,三個關鍵幀, 根據A和C的插值結果, 與B比對, 如果非常近似, 那么可以去掉B.
    在比對的時候, 要比對位置和旋轉, 所以我用了兩個precision threshold - 角度誤差和位置誤差, 來相對直觀的控制精度. 對於根據骨骼節點深度加大精度誤差的做法, 我也嘗試了, 感覺差別不是很大.
    代碼見下:
    #if OPTIMIZE_FRAME
            //accumulated error
            float accumAngle = Blade::Math::Degree2Radian(mConfig.mAngleError);
            float accumPos = mConfig.mPositionError;
            
            float angleThreshold = accumAngle / maxBoneDepth;
            float posThreshold = accumPos / maxBoneDepth;
    
            BoneKeyframeList::iterator  start = keyFrames.begin();
            for(int i = 0; i < mBoneList.size(); ++i)
            {
                size_t keyCount = keyCountList[i];
                BoneKeyframeList::iterator iter = start + 1;
                for(size_t index = 1; index+1 < keyCount; )
                {
                    //assert( std::distance(start, iter) == index ); //debug too slow, uncomment if needed
                    const KeyFrame& kf = *iter;
                    const KeyFrame& prev = *(iter - 1);
                    const KeyFrame& next = *(iter + 1);
                    scalar t = (kf.getTimePos() - prev.getTimePos()) / (next.getTimePos() - prev.getTimePos());
                    assert( t > 0 && t < 1); //possibly iter position error across two bone key frame sequences
                    BoneDQ interpolated = prev.getTransform();
                    interpolated.sclerpWith(next.getTransform(), t, true);
                    interpolated.normalize();
    
                    const BoneDQ& dq = kf.getTransform();
                    if( interpolated.getRotation().equal(dq.getRotation(), angleThreshold)
                        && interpolated.getTranslation().equal( dq.getTranslation(), posThreshold)
                        )
                    {
                        iter = keyFrames.erase(iter);
                        --keyCount;
                    }
                    else
                    {
                        ++iter;
                        ++index;
                    }
                }
                keyCountList[i] = keyCount;
                start = iter;
            }
    #endif

    目前最大累積角度誤差默認取的是0.4角度, 最大累積位置誤差取的是0.004個單位. 如果太大的話動畫感覺很松動不流暢, 動作幅度也變小, 產生嚴重的失真. 這兩個參數可以通過導出界面配置, 不過一般來說, 美術不需要修改.

  • 采樣率的選擇. max默認的FPS是25, 記得以前看過一篇文章說, 人眼可以觀察到的動畫,極限FPS是30, 超過30以后, 人眼也看不出差別, 所以高於30沒有意義. 我在網上搜到的動畫采樣配置, 有FPS=12的. 為了減少數據量, 個人覺得15FPS左右就可以, 剩下的數據在runtime插值出來. 除非對動畫質量有很高的要求, 才需要使用30FPS. 事實上如果FPS>=30, 那么甚至可以不用runtime插值, 比如之前看到過的某些動畫代碼, 根本沒有插值, 而是將時間直接轉為frame id去索引關鍵幀. 

通過以上方法, 之前那個70M可以減到20M的骨骼文件, litch king 阿爾薩斯, 在3ds max中有18195個關鍵幀. 現在在采樣率為25的情況下, 骨骼文件大小為3.9M, 在采樣率為15的情況下, 骨骼文件大小為2.9M, 而最終動畫效果可以接受.

除此之外, 我們公司的動畫, 在某些平台上, 還使用了一種變率(VBR)的浮點壓縮方式, 不過沒有仔細研究也沒去搜paper, 大致原理是根據不同的浮點精度, 使用不同的位數來存放.這個確實蠻屌的,但是有精度損失, 可能會有輕微抖動.Blade暫時不使用這種方式.

 

6.運行時優化 Runtime Optimization

骨骼動畫的計算是渲染中比較耗CPU的部分, 所以優化是必須的, 這是我目前想到的和已經做的優化:

  • 使用SIMD, 這個我是把DirectXMath拿過來, 做了相對的調整, 跟DXTK的SimpleMath一樣, 嵌入到已有的數學類里面, 做到無縫接合, 並且可以隨時關閉(當然需要重編譯). 經過測試, 效率確實要比編譯器自動SSE優化的快.
  • 數據緊湊 - Compact Data: 不使用節點, 使用有序數組提高cache效率, 這樣更能夠發揮SIMD的優勢.
    因為骨骼節點嘛, 通常都會先想到Node, 事實上需要傳入shader的, 只是一個數組. 通常樹的節點遍歷, 有大量的間接尋址和函數調用. 使用有序數組的cache效率會更高, 甚至可以把這個計算結果直接傳給shader.
    比如Ogre的Bone, 是繼承自基類Node, 本身Node類就很龐大復雜, 導致Bone的雖然代碼簡單, 但是實際上數據很復雜, 有很多冗余成員數據. 關於復用, 我個人覺得, 除了基礎代碼盡量復用以外, 任何時候, 復用的都最好是接口(設計), 這樣才能減少代碼的侵入性, 減少掣肘, 使得子模塊高度定制, 保證其有簡單高效的實現.
    整個骨骼的遍歷是要求有順序的, 如果用樹表示的話, 是先根遍歷(兄弟之間無所謂), 如果用數組就需要排序.
    還記得數據結構里面的"二叉樹的數組表示"法么?
    然而, 即便是數組表示的樹, 普通的Node有額外的信息, 比如兄弟指針(索引),父指針(索引)等等, 而傳入shdader的BonePalette也只是一個DQ數組(以前喜歡叫bone matrices, 現在用了DQ,雖然代碼里面叫BoneDQ, 但Bone Palette是更一般的叫法).
    如果想只使用DQ數組, 就得把父節點這些數據單獨分開存放(畢竟這些是寫在資源里的固定數據,所以不難), 而且要預先排序, 而不需要顯式的父子順序, 然后按順序計算更新就可以了.
    因為boneIndices是預生成以后保存在模型/骨骼數據里面的,shader里面要用它索引BonePalette, 所以運行時不能再排序, 否則索引就會出錯. 所以這個在導出時排序最好: 把父節點放在子節點之前(兄弟之間無所謂), 這樣的順序不是嚴格有序, 但是足夠滿足需求了.
     1     struct BoneCollector : public ITreeEnumProc
     2     {
     3         BoneList& listRef;
     4         BoneCollector(BoneList& list) :listRef(list){}
     5 
     6         virtual int callback(INode* node)
     7         {
     8             if( IsBoneNode(node) )
     9             {
    10                 IGameScene* game = ::GetIGameInterface();
    11                 listRef.push_back( game->GetIGameNode(node) );
    12             }
    13             return TREE_CONTINUE;
    14         }
    15     }collector(mBoneList);
    16     ei->theScene->EnumTree(&collector);
    17 
    18     //important: sort bones so that parent comes first, this is an optimization for animation runtime
    19     struct FnIGameBoneCompare
    20     {
    21         //check if rhs is descendant of lhs
    22         inline bool isDescendant(IGameNode* left, IGameNode* right) const
    23         {
    24             while(right->GetNodeParent() != NULL)
    25             {
    26                 right = right->GetNodeParent();
    27                 if( left == right )
    28                     return true;
    29             }
    30             return false;
    31         }
    32 
    33         bool operator()(IGameNode* lhs, IGameNode* rhs) const
    34         {
    35             if( this->isDescendant(lhs, rhs) )
    36                 return true;
    37             else
    38                 return false;
    39         }
    40     };
    41 
    42     std::sort( mBoneList.begin(), mBoneList.end(), FnIGameBoneCompare() );

    運行時, 更新完動畫混合/插值以后, 只需要按順序更新數組就可以了, 有先天的cache優勢:

     1                 //update bone hierarchy & calculate bone transforms
     2                 for(size_t i = 0; i < boneCount; ++i)
     3                 {    
     4                     mBoneDQ[i].normalize();
     5 
     6                     //apply hierarchy
     7                     uint32 parent = boneData[i].mParent;
     8                     if( parent != uint32(-1) )
     9                     {
    10                         //bones already sorted in linear order (by animation exporter), parent always calculated before children
    11                         assert( parent < (uint32)i );
    12                         //apply hierarchy:
    13 
    14                         //note: parent is already applied inversed binding pose, need to get it back
    15                         const BoneDQ& parentBindingPose = boneData[parent].mInitialPose;
    16                         mBoneDQ[i] = mBoneDQ[parent]*mBoneDQ[i];
    17                     }
    18                     else
    19                         ;//mBoneDQ[i] = mBoneDQ[i];
    20                 }
    21                 
    22                 //apply animations
    23                 for(size_t i = 0; i < boneCount; ++i)
    24                 {
    25                     //reset bone matrices to init pose (T pose) to prepare animation
    26                     const BoneDQ& tposeDQ = boneData[i].mInitialPose;
    27 
    28                     //note: tposeDQ is normalized after loading and never modified
    29                     //and Inverse(dq) == Conjugate(dq), if dq is normalized
    30                     BoneDQ inversedBindingPose = tposeDQ.getConjugate();
    31                     
    32                     mBoneDQ[i] = mBoneDQ[i]*inversedBindingPose;
    33                 }
    34             }

     

  • 單遍遍歷: 避免多次內存訪問, 因為多以次遍歷的話, CPU流水線可能需要reload cache, 這個過程可能要比數學指令慢很多. 這處修改還沒有profile, 有空的話去看看這個做法到底對不對.
    現在是One Pass就完成了所有的Bone Palette Update了, 不過有冗余的計算(基於上面代碼做了簡單修改):

     1                 //update bone hierarchy & calculate bone transforms
     2                 for(size_t i = 0; i < boneCount; ++i)
     3                 {    
     4                     mBoneDQ[i].normalize();
     5 
     6                     //reset bone matrices to init pose (T pose) to prepare animation
     7                     const BoneDQ& tposeDQ = boneData[i].mInitialPose;
     8 
     9                     //note: tposeDQ is normalized after loading and never modified
    10                     //and Inverse(dq) == Conjugate(dq), if dq is normalized
    11                     BoneDQ inversedBindingPose = tposeDQ.getConjugate();
    12 
    13                     //apply hierarchy & animations
    14                     uint32 parent = boneData[i].mParent;
    15                     if( parent != uint32(-1) )
    16                     {
    17                         //bones already sorted in linear order (by animation exporter), parent always calculated before children
    18                         assert( parent < (uint32)i );
    19                         //apply hierarchy:
    20 
    21                         //note: parent is already applied inversed binding pose, need to get it back
    22                         const BoneDQ& parentBindingPose = boneData[parent].mInitialPose;
    23                         mBoneDQ[i] = (mBoneDQ[parent]*parentBindingPose)*mBoneDQ[i]*inversedBindingPose;
    24                     }
    25                     else
    26                         mBoneDQ[i] = mBoneDQ[i]*inversedBindingPose;
    27                 }
    28             }

     

    這樣, 計算出的結果可以直接丟給shader, 一個動畫的所有mesh只需要傳一次shader就可以了.
    不過這樣做的話, 整個動畫的骨骼數量就太受限了. 為了突破骨骼數量限制, 可以像Ogre那樣, 對於每個mesh保存一個shader cache, 每個mesh從骨骼的計算結果里復制需要的數據, 傳一次shader constant, 這樣每個mesh的骨骼數量有限制, 但是整個動畫沒有了.

  • 在需要的地方加上必要的memory prefetch.

  • 另外還可以考慮多線程, 這個目前沒有計划.

 

7. UI

UI遇到了一些惡心的問題, 主要是之前的UI不滿足需求...

proerpty grid + 數據綁定遇到的問題:

動畫列表想用下拉框, 但是無法實現. 目前數據綁定的下拉列表選項是固定的, 選中的選項被綁定到類成員數據或者函數上. 但是動畫的列表不是靜態的, 需要跟綁定對象關聯, 才能做到.
這個功能在現有機制上可以添加, 但是不想改UI了, 所以改用其他方式:
用collection的數據綁定, 可以展開多個item, 選中item的事件在編輯端處理, 並發送動畫變更給動畫組件, 完成動畫的切換.


導出動畫的配置界面, 也需要復雜的UI. 比如單個動畫序列, 需要有名字,起始幀,結束幀, 是否循環等等, 如果要導出多個動畫, 現有的UI很難滿足需求.目前的workaround是導出多個動畫序列時, 使用配置文件...不過由於動畫可能是由不同的artist制作的, 而且在游戲開發過程中, 會不停加入新內容, 所以一般最好的方式是一個一個導出, 然后用工具合並, 想到這里, 就暫時沒有改動UI了, 勉強先這樣用.

 

trackview - 簡單的接口定義. 為了實現UI與具體的邏輯解耦, 即UI可以handle不同類型的數據, 比如以后的過場動畫(CutScene即in-game cinematic)的視軌編輯, 做了以下抽象:

 1     /************************************************************************/
 2     /*                                                                      */
 3     /************************************************************************/
 4     class ITrack
 5     {
 6     public:
 7         typedef enum ETrackFeature
 8         {
 9             TF_SEEK        = 0x00000001,
10             TF_SETLENGTH= 0x00000002,
11             TF_KEYFRAME    = 0x00000004|TF_SEEK,
12             TF_ADDKEY    = 0x00000008|TF_KEYFRAME,
13             TF_REMOVEKEY= 0x000000010|TF_KEYFRAME,
14         }FEATURE_MASK;
15     public:
16         /* @brief  */
17         virtual scalar    getDuration() const = 0;
18         /* @brief get current play pos */
19         virtual scalar    getPosition() const = 0;
20         /* @brief FEATURE_MASK  */
21         virtual int        getFeatures() const = 0;
22 
23         /* @brief  */
24         virtual bool    play() = 0;
25         /* @brief */
26         virtual bool    pause() = 0;
27         /* @brief  */
28         virtual bool    isPlaying() const = 0;
29 
30         /* @brief get current animation name, if have any */
31         virtual const tchar* getCurrentAnimation() const    {return NULL;}
32 
33         /* @brief TF_SEEK */
34         virtual bool    setPosition(scalar pos)        {BLADE_UNREFERENCED(pos); return false;}
35 
36         /* @brief TF_SETLENGTH */
37         virtual bool    setDuration(scalar length)    {BLADE_UNREFERENCED(length); return false;}
38 
39         /* @brief TF_KEYFRAME */
40         virtual size_t    getKeyFrameCount() const    {return 0;}
41         virtual scalar    getKeyFrame() const            {return 0;}
42 
43         /* @brief TF_ADDKEY */
44         virtual index_t    addKeyFrame(scalar pos)        {BLADE_UNREFERENCED(pos); return INVALID_INDEX;}
45 
46         /* @brief TF_REMOVEKEY */
47         virtual bool    removeKeyFrame(index_t index){BLADE_UNREFERENCED(index); return false;}
48 
49     };//class ITrack
50 
51 
52     /************************************************************************/
53     /*                                                                      */
54     /************************************************************************/
55     class BLADE_EDITOR_API ITrackManager : public InterfaceSingleton<ITrackManager>
56     {
57     public:
58         virtual ~ITrackManager() {}
59 
60         /* @brief  */
61         virtual size_t    getTrackCount() const = 0;
62 
63         /* @brief get bound track */
64         virtual ITrack*    getTrack(index_t index) const = 0;
65 
66         /* @brief  */
67         virtual index_t    getTrackIndex(ITrack* track) const = 0;
68 
69         /* @brief bind track to view */
70         virtual bool    addTrack(ITrack* track) = 0;
71 
72         /* @brief  */
73         virtual bool    removeTrack(index_t index) = 0;
74         inline bool    removeTrack(ITrack* track)
75         {
76             return this->removeTrack(this->getTrackIndex(track));
77         }
78     };

有了以上接口, 就可以綁定到給UI, 用來顯示和多動播放進度等等. 當然目前的設計還很簡單, 需要繼續完善, 比如多個track的編輯和插入關鍵幀,編輯關鍵幀等等. 其實要做好這一塊還是很難的, 如果要兼顧復雜度和用戶體驗的話, 需要花精力慢慢做.


trackview的實現, 這個沒什么說的了. 但是遇到了一個有點詭異的東西: MFC的 CSlider事件, 用NM_CUSTOMDRAW不行. 比如CEdit的EN_SELCHANGE, 只有用戶改變的時候才會發消息, 而CSlider的NM_CUSTOMDRAW, 代碼里更改了slider的位置, 也會發這個消息, 這不符合需求. 最后用的是Scroll事件 - 是的, CSlider會給父窗口發滾動事件. 最詭異的就是這里的強制類型轉換, 把輸入參數CScrollBar轉換為CSlider.

 1 void CTrackViewUI::OnHScroll(UINT /*nSBCode*/, UINT /*nPos*/, CScrollBar* pScrollBar)
 2 {
 3     if( mTrack != NULL && (mTrack->getFeatures()&ITrack::TF_SEEK) )
 4     {
 5         CSliderCtrl* slider = reinterpret_cast<CSliderCtrl*>( pScrollBar );
 6         assert( slider == this->GetDlgItem(IDC_TRACKVIEW_TRACK) );    //this is the only slider/scrollbar we have.
 7         if( slider != NULL )
 8         {
 9             int pos = slider->GetPos();
10             mTrack->pause();
11             mTrack->setPosition( (scalar)pos / (scalar)mFPS );
12             this->updateUI(true);
13         }
14     }
15 }

這里的reinterpret_cast (line 5) 有點詭異和丑陋, 明顯有點生硬的感覺, 但是還好有詳細的文檔 http://msdn.microsoft.com/en-us/library/ekx9yz55.aspx.

所以MS的開發者友好度大贊, 比android什么的強了不止幾倍, 不過MSDN也是積累了n年才有如此好的開發生態圈, android目前確實比不了.

 

8.遺留問題

  • 目前骨骼數量定的是120, 對於256個vs constant, 除了骨骼變換數組占用的120x2=240個, 還剩下16個, 雖然有點少, 目前夠用了, 沒有什么問題. 如果單個mesh的骨骼數量太多(> 120), 需要分割mesh, 拆分到多個draw call里, 這個暫時先不做.
  • 動畫混合樹: 這個以后慢慢完善.
  • 動畫合並工具: 這個准備作為模型瀏覽器(modelviewer)插件的一個功能, 集成在編輯器里, 以后慢慢完善.
  • IK, 下一步着手去做.

 

最后還是慣例, 發截圖:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM