本文主要討論兩個骨骼動畫過渡時的混合效果。
在遊戲中,動畫往往被切分成多個片段(clip),通過組合拼接來構建最終的表現效果。爲了確保切換動作時的平滑過渡,使得整體動作更加流暢,需要對前後動作通過插值進行混合。
概念引入
在討論具體的混合計算之前,我想依然有必要明確一下整個動畫系統體系的一些細節。
其中一個需要探討的問題在於:動畫運行時應該維護哪些數據,並以怎樣的形式換算進入最終的渲染流程。在原先的骨骼動畫demo中,我實際上是按幀存儲了每幀每個骨骼的蒙皮矩陣,作爲骨骼動畫演示已經綽綽有餘了。
但對於實際應用而言,這裏起碼存在了兩個問題:一是逐幀的動畫數據一般是烘焙的結果,在dcc工具中動畫數據一般爲關鍵幀+曲線類型存儲,實時插值本身並不耗時,並且可以壓縮帶寬,所以動畫往往都會進行壓縮存儲;
二是我們除了播放動畫外,有時候還需要對動作做一些後處理——比如本篇文章討論的動畫混合,又比如反向動力學效果,此時僅有蒙皮矩陣是不利於運算的,我們應該根據自己的實際需求,存儲爲局部變換矩陣或全局(模型空間)變換矩陣。特別地,如果存儲爲局部變換矩陣,在解算的過程中存在不斷查找父節點連乘矩陣的步驟,此處合理地安排骨骼的排序可以避免重複的運算。
另外一個需要考慮的問題是,混合是否應該影響到動作的播放長度?我們假設動作A長度爲ta幀,B爲tb幀,混合幀數爲tc,那麼動作A,B混合後,總幀長應該爲ta+tb,還是ta+tb+tc,又或是介於兩者之間?
針對這一問題,我認爲比較友好的設計爲:混合時間不應該影響原播放長度,混合這一功能本身僅僅是爲了更好的表現效果,它不應當破壞原有的體系。那麼此處就必然有一個動作“犧牲”一部分姿態。一個比較常見的思路是讓目標動作的前tc幀轉換爲過渡幀,每一幀的矩陣與源動作最後一幀按照當前時間進行權重插值。
具體實現
爲了更好地進行混合操作,我們可以將動畫存儲的數據修改爲模型空間下的transform值,而不是最終的蒙皮矩陣。所謂的transform也就是分別存儲平移旋轉縮放,之後再根據RST進行矩陣構造:
struct STransform
{
QVector3D position = QVector3D(0, 0, 0);
QVector3D scale = QVector3D(1, 1, 1);
QQuaternion rotation = QQuaternion(0, 0, 0, 1);
};
在實際的插值運算中,我們也是針對每個骨骼模型空間的平移、旋轉、縮放分別進行插值(由於項目中骨骼不存在縮放,實際的工程中並沒有計算縮放插值)
在切換到新動作,當我們檢測到新動作需要混合時,我們根據當前動作localtime,採樣動作的transform值,作爲緩存:
void CAnimationEngine::PlayAnimation(Object* obj, const string& path)
{
if(m_animators.find(path) == m_animators.end())
{
return;
}
int frame = -1;
string oldPath;
// check need blend, save cache pose
if(m_events.find(obj) != m_events.end() && m_animators.find(m_events[obj].m_path) != m_animators.end())
{
if(g_animParam.m_nBlendFrame)
{
oldPath = m_events[obj].m_path;
frame = min(m_animators[oldPath].GetFrameNum(), static_cast<int>(m_events[obj].m_time * FRAME_PER_MS));
}
}
m_events[obj] = SEvent(path, g_animParam.m_bLoop, g_animParam.m_nBlendFrame, g_animParam.m_eBlendCurve, g_animParam.m_fSpeed);
if(!oldPath.empty())
{
CAnimator& animator = m_animators[oldPath];
m_events[obj].m_cachePose = animator.GetTransform(frame);
}
}
接下來,在更新骨骼動畫的代碼中,我們對當前幀數下的採樣動作和緩存動作的平移、旋轉分別進行插值,混合權重以時間t單位,包含線性混合(t),以及非線性混合(3 * t * t - 2 * t * t)。
插值結束後,我們重新構造模型空間的全局變換矩陣,並乘以綁定矩陣逆矩陣構造蒙皮矩陣,傳遞給着色器。
bool CAnimationEngine::UpdateAnimation(Object* obj, QOpenGLShaderProgram* program)
{
// ...
if (event.m_blendFrame > 0 && frame <= event.m_blendFrame && event.m_cachePose.size() > 0)
{
vector<QMatrix4x4> final;
float ratio = static_cast<float>(frame + 1) / (event.m_blendFrame + 1);
if (event.m_eBlendCurve == EBlendCurve::Smooth)
{
ratio = ratio * ratio * (-2 * ratio + 3);
}
for(int i = 0;i < size; i++)
{
STransform& transform = animator.GetTransform(frame, i);
QQuaternion quat = QQuaternion::slerp(event.m_cachePose[i].rotation, transform.rotation, ratio);
QVector3D trans = Lerp(event.m_cachePose[i].position, transform.position, ratio);
QMatrix4x4& invBindPose = CAnimationEngine::Inst()->GetBone(i)->m_invBindPose;
QMatrix4x4 mat;
mat.translate(trans);
mat.rotate(quat);
mat = mat * invBindPose;
final.push_back(mat);
}
program->setUniformValueArray(location,final.data(), size);
}
else
{
// ...
}
// ...
return true;
}