首先貼一個介紹max的sdk和骨骼動畫的文章, 雖然很早的文章, 但是很有用, 感謝前輩們的貢獻:
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, 下一步着手去做.
最后還是慣例, 發截圖: