第8章 动画(Animation)
一、导读
1.1 本章学习目标
动画是赋予静态三维场景以生命力的技术。从皮克斯电影中栩栩如生的角色,到游戏中流畅的动作表现,从建筑可视化中太阳轨迹的演示,到科学模拟中分子运动的可视化——动画技术无处不在。计算机动画的核心挑战是如何高效地描述、控制和生成随时间变化的视觉内容。
本章的核心学习目标包括:
- 理解动画的基本原理:掌握帧率、时间线、关键帧等基础概念,理解动画的数学本质——随时间变化的参数插值。
- 掌握关键帧动画技术:深入理解关键帧的定义、插值方法(线性、样条、贝塞尔),以及运动曲线(缓入缓出)的设计。
- 理解骨骼动画系统:掌握骨架结构、正向运动学(FK)、逆向运动学(IK)、蒙皮和权重绘制的原理。
- 掌握物理动画基础:了解刚体动力学、布料模拟和粒子系统的基本原理和数值积分方法。
- 了解动作捕捉技术:理解从真人表演获取动画数据的工作流程,以及数据清理和重定向技术。
- 实现完整的动画系统:在 OpenGL 中实现骨骼动画的蒙皮渲染,包括骨骼变换、权重混合和 GPU 蒙皮。
1.2 为什么本章如此重要
动画是游戏开发和影视特效的核心技术。一个精美的三维模型如果没有流畅自然的动画,就像一个精致的木偶没有操纵者——毫无生气。现代 3A 游戏中的角色可能有数百个骨骼、数千个关键帧动画片段,以及复杂的动画状态机来控制它们之间的切换。
从技术角度看,动画系统涉及图形学中多个领域的交叉:变换矩阵(骨骼变换)、曲线理论(插值曲线)、物理模拟(动力学)、信号处理(动作数据滤波)。理解动画的原理,你才能:
- 正确设置骨骼动画的权重和混合
- 实现自然的角色运动控制
- 调试动画中的穿模、滑步等问题
- 优化动画系统的性能(如 LOD、GPU 蒙皮)
此外,动画技术不仅限于角色动画。UI 动画(按钮的缓动效果)、数据可视化(动态图表)、建筑可视化(日照分析)等领域都需要动画技术。掌握动画原理,你的应用将更具交互性和视觉吸引力。
1.3 核心问题引导
在阅读本章时,请带着以下问题思考:
为什么 24fps 是电影的最低帧率,而游戏通常需要 60fps?人眼对帧率的感知有什么特点?
线性插值为什么在动画中看起来"机械"?缓入缓出曲线为什么更符合自然运动的直觉?
骨骼动画中,一个顶点可以受多个骨骼影响,权重之和为什么必须为 1?如果权重之和不为 1 会出现什么问题?
逆向运动学(IK)为什么比正向运动学(FK)更难求解?CCD 算法和解析法各有什么优劣?
物理动画中的显式欧拉积分为什么可能导致不稳定?半隐式欧拉和 Verlet 积分如何改善稳定性?
动画状态机如何管理复杂的动画切换?什么是动画混合树(Blend Tree)?
二、核心概念详解
2.1 动画基础
2.1.1 帧率与时间
动画的本质是快速连续显示一系列静态图像,利用人眼的视觉暂留效应产生运动错觉。
帧率标准:
- 电影:24 fps(每秒 24 帧)
- 电视(PAL):25 fps
- 电视(NTSC):30 fps
- 游戏:30/60/120 fps
- VR:90/120 fps(减少眩晕)
时间步长:
dt = 1.0 / fps
24 fps → dt ≈ 41.7ms
60 fps → dt ≈ 16.7ms
120 fps → dt ≈ 8.3ms2.1.2 动画循环
// 基本的动画更新循环
class AnimationSystem {
public:
void update(float deltaTime) {
currentTime += deltaTime * speed;
// 循环播放
if (loop) {
currentTime = std::fmod(currentTime, duration);
} else {
currentTime = std::min(currentTime, duration);
}
// 更新所有动画实例
for (auto& instance : instances) {
instance.update(currentTime);
}
}
void play(const std::string& animName) {
auto it = animations.find(animName);
if (it != animations.end()) {
currentAnimation = &it->second;
currentTime = 0.0f;
playing = true;
}
}
private:
std::map<std::string, AnimationClip> animations;
AnimationClip* currentAnimation = nullptr;
float currentTime = 0.0f;
float speed = 1.0f;
bool loop = true;
bool playing = false;
std::vector<AnimationInstance> instances;
};2.2 关键帧动画
2.2.1 关键帧与插值
关键帧动画的核心思想是:只定义关键时间点上的值(关键帧),中间帧通过插值自动生成。
// 关键帧定义
template<typename T>
struct Keyframe {
float time;
T value;
};
// 关键帧动画轨道
template<typename T>
class AnimationTrack {
public:
void addKeyframe(float time, const T& value) {
keyframes.push_back({time, value});
// 保持按时间排序
std::sort(keyframes.begin(), keyframes.end(),
[](const Keyframe<T>& a, const Keyframe<T>& b) {
return a.time < b.time;
});
}
T evaluate(float time) const {
if (keyframes.empty()) return T();
if (time <= keyframes.front().time) return keyframes.front().value;
if (time >= keyframes.back().time) return keyframes.back().value;
// 找到相邻的两个关键帧
size_t i = 0;
for (; i < keyframes.size() - 1; i++) {
if (time < keyframes[i + 1].time) break;
}
const auto& kf0 = keyframes[i];
const auto& kf1 = keyframes[i + 1];
// 计算插值因子
float t = (time - kf0.time) / (kf1.time - kf0.time);
// 应用缓动函数
t = easingFunction(t);
return interpolate(kf0.value, kf1.value, t);
}
private:
std::vector<Keyframe<T>> keyframes;
};
// 不同类型的插值
float interpolate(float a, float b, float t) {
return a + (b - a) * t; // 线性插值
}
glm::vec3 interpolate(const glm::vec3& a, const glm::vec3& b, float t) {
return glm::mix(a, b, t);
}
// 四元数插值(SLERP)
glm::quat interpolate(const glm::quat& a, const glm::quat& b, float t) {
return glm::slerp(a, b, t);
}2.2.2 缓动函数(Easing Functions)
线性插值产生机械感的匀速运动。缓动函数通过改变插值的速度曲线,产生更自然的运动效果。
// 常用缓动函数
namespace Easing {
// 线性
float linear(float t) { return t; }
// 缓入(二次)
float easeInQuad(float t) { return t * t; }
// 缓出(二次)
float easeOutQuad(float t) { return t * (2 - t); }
// 缓入缓出(二次)
float easeInOutQuad(float t) {
return t < 0.5f ? 2 * t * t : -1 + (4 - 2 * t) * t;
}
// 缓入(三次)
float easeInCubic(float t) { return t * t * t; }
// 缓出(三次)
float easeOutCubic(float t) {
float t1 = t - 1;
return t1 * t1 * t1 + 1;
}
// 缓入缓出(三次)
float easeInOutCubic(float t) {
return t < 0.5f ? 4 * t * t * t :
(t - 1) * (2 * t - 2) * (2 * t - 2) + 1;
}
// 弹性效果
float easeOutElastic(float t) {
if (t == 0 || t == 1) return t;
return std::pow(2, -10 * t) *
std::sin((t - 0.1f) * 5 * M_PI) + 1;
}
// 弹跳效果
float easeOutBounce(float t) {
if (t < 1 / 2.75f) {
return 7.5625f * t * t;
} else if (t < 2 / 2.75f) {
t -= 1.5f / 2.75f;
return 7.5625f * t * t + 0.75f;
} else if (t < 2.5f / 2.75f) {
t -= 2.25f / 2.75f;
return 7.5625f * t * t + 0.9375f;
} else {
t -= 2.625f / 2.75f;
return 7.5625f * t * t + 0.984375f;
}
}
}
// 使用缓动函数
template<typename T>
T AnimationTrack<T>::evaluate(float time) const {
// ... 找到关键帧 ...
float t = (time - kf0.time) / (kf1.time - kf0.time);
t = Easing::easeInOutCubic(t); // 应用缓动
return interpolate(kf0.value, kf1.value, t);
}2.2.3 样条插值
当需要更平滑的曲线时,可以使用样条插值(如 Catmull-Rom 样条)代替分段线性插值。
// Catmull-Rom 样条插值
// 使用 4 个控制点,生成经过中间两点的平滑曲线
glm::vec3 catmullRom(const glm::vec3& p0, const glm::vec3& p1,
const glm::vec3& p2, const glm::vec3& p3,
float t) {
float t2 = t * t;
float t3 = t2 * t;
return 0.5f * (
(2.0f * p1) +
(-p0 + p2) * t +
(2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * t2 +
(-p0 + 3.0f * p1 - 3.0f * p2 + p3) * t3
);
}
// 在关键帧动画中使用样条插值
glm::vec3 splineEvaluate(const std::vector<Keyframe<glm::vec3>>& keyframes,
float time) {
// 找到当前时间所在的区间
size_t i = findKeyframeInterval(keyframes, time);
// 获取 4 个控制点(前后各一个)
const auto& p0 = keyframes[std::max(0, (int)i - 1)].value;
const auto& p1 = keyframes[i].value;
const auto& p2 = keyframes[i + 1].value;
const auto& p3 = keyframes[std::min(keyframes.size() - 1, i + 2)].value;
float t = (time - keyframes[i].time) /
(keyframes[i + 1].time - keyframes[i].time);
return catmullRom(p0, p1, p2, p3, t);
}2.3 骨骼动画
2.3.1 骨架结构
骨骼动画使用骨架(Skeleton)来驱动模型变形。骨架由一系列骨骼(Bone)组成,形成树状层次结构。
// 骨骼定义
struct Bone {
int id;
std::string name;
int parentId; // -1 表示根骨骼
glm::mat4 offsetMatrix; // 骨骼的逆绑定姿态矩阵
glm::mat4 localTransform; // 当前局部变换
};
// 骨架
struct Skeleton {
std::vector<Bone> bones;
std::vector<glm::mat4> boneMatrices; // 最终的全局变换矩阵
// 更新骨骼变换(从根到叶递归计算)
void updateBoneMatrices() {
boneMatrices.resize(bones.size());
for (size_t i = 0; i < bones.size(); i++) {
const auto& bone = bones[i];
if (bone.parentId == -1) {
boneMatrices[i] = bone.localTransform;
} else {
boneMatrices[i] = boneMatrices[bone.parentId] *
bone.localTransform;
}
}
}
// 计算最终的蒙皮矩阵
std::vector<glm::mat4> getSkinMatrices() const {
std::vector<glm::mat4> skinMatrices(bones.size());
for (size_t i = 0; i < bones.size(); i++) {
skinMatrices[i] = boneMatrices[i] * bones[i].offsetMatrix;
}
return skinMatrices;
}
};2.3.2 蒙皮(Skinning)
蒙皮定义了模型顶点与骨骼之间的关系。每个顶点受最多 4 个骨骼影响,每个骨骼有一个权重值。
// 带骨骼权重的顶点
struct SkinnedVertex {
glm::vec3 position;
glm::vec3 normal;
glm::vec2 texCoord;
int boneIds[4]; // 影响的骨骼 ID
float boneWeights[4]; // 对应的权重
};
// CPU 蒙皮计算
void computeSkinning(std::vector<SkinnedVertex>& vertices,
const std::vector<glm::mat4>& skinMatrices) {
for (auto& vertex : vertices) {
glm::mat4 skinMatrix(0.0f);
for (int i = 0; i < 4; i++) {
if (vertex.boneWeights[i] > 0.001f) {
skinMatrix += vertex.boneWeights[i] *
skinMatrices[vertex.boneIds[i]];
}
}
vertex.skinnedPosition = skinMatrix * glm::vec4(vertex.position, 1.0f);
vertex.skinnedNormal = glm::vec3(
skinMatrix * glm::vec4(vertex.normal, 0.0f));
}
}2.3.3 GPU 蒙皮
GPU 蒙皮将蒙皮计算转移到顶点着色器中,利用 GPU 的并行计算能力,性能远优于 CPU 蒙皮。
// GPU 蒙皮顶点着色器
#version 450 core
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTexCoord;
layout(location = 3) in ivec4 aBoneIds;
layout(location = 4) in vec4 aBoneWeights;
uniform mat4 uModel;
uniform mat4 uView;
uniform mat4 uProjection;
uniform mat4 uBoneMatrices[128]; // 最多 128 个骨骼
out vec3 vWorldPos;
out vec3 vNormal;
out vec2 vTexCoord;
void main() {
// 计算蒙皮矩阵
mat4 skinMatrix = mat4(0.0);
for (int i = 0; i < 4; i++) {
if (aBoneWeights[i] > 0.001) {
skinMatrix += aBoneWeights[i] * uBoneMatrices[aBoneIds[i]];
}
}
// 应用蒙皮变换
vec4 skinnedPos = skinMatrix * vec4(aPosition, 1.0);
vec4 worldPos = uModel * skinnedPos;
vWorldPos = worldPos.xyz;
// 法线变换(使用蒙皮矩阵的上 3x3 部分)
mat3 normalMatrix = transpose(inverse(mat3(uModel) * mat3(skinMatrix)));
vNormal = normalize(normalMatrix * aNormal);
vTexCoord = aTexCoord;
gl_Position = uProjection * uView * worldPos;
}// 上传骨骼矩阵到 GPU
void uploadBoneMatrices(GLuint shader,
const std::vector<glm::mat4>& skinMatrices) {
GLint location = glGetUniformLocation(shader, "uBoneMatrices");
glUniformMatrix4fv(location, skinMatrices.size(), GL_FALSE,
glm::value_ptr(skinMatrices[0]));
}2.4 逆向运动学(IK)
2.4.1 FK vs IK
正向运动学(FK):从根骨骼开始,逐级设置每个关节的旋转角度,末端位置由正向计算得到。
逆向运动学(IK):给定期望的末端位置,反向计算各关节需要的旋转角度。
FK:根 → 关节1 → 关节2 → 末端
设置角度 → 设置角度 → 设置角度 → 计算位置
IK:根 → 关节1 → 关节2 → 末端
给定期望位置 → 反算角度 → 反算角度 → 验证位置2.4.2 CCD(Cyclic Coordinate Descent)算法
CCD 是最常用的 IK 求解算法之一。它从末端骨骼开始,逐个调整每个关节的旋转,使末端尽量接近目标。
// CCD IK 求解器
class CCDIK {
public:
struct Joint {
glm::vec3 position;
glm::quat rotation;
int parentIndex;
};
bool solve(std::vector<Joint>& chain, glm::vec3 target,
float tolerance, int maxIterations) {
int endEffector = chain.size() - 1;
for (int iter = 0; iter < maxIterations; iter++) {
// 从倒数第二个关节开始(末端不需要旋转)
for (int i = chain.size() - 2; i >= 0; i--) {
// 计算当前末端位置
glm::vec3 endPos = getEndEffectorPosition(chain);
glm::vec3 jointPos = chain[i].position;
// 计算从关节到末端和目标的方向
glm::vec3 toEnd = glm::normalize(endPos - jointPos);
glm::vec3 toTarget = glm::normalize(target - jointPos);
// 计算旋转
glm::quat deltaRot = glm::rotation(toEnd, toTarget);
// 限制旋转角度(可选)
float angle = glm::angle(deltaRot);
if (angle > maxAnglePerIteration) {
float scale = maxAnglePerIteration / angle;
deltaRot = glm::slerp(glm::quat(1, 0, 0, 0), deltaRot, scale);
}
// 应用旋转
chain[i].rotation = deltaRot * chain[i].rotation;
// 更新后续关节位置
updateChainFrom(chain, i);
}
// 检查是否满足精度
glm::vec3 endPos = getEndEffectorPosition(chain);
if (glm::length(endPos - target) < tolerance) {
return true;
}
}
return false;
}
private:
float maxAnglePerIteration = glm::radians(5.0f);
glm::vec3 getEndEffectorPosition(const std::vector<Joint>& chain) {
return chain.back().position;
}
void updateChainFrom(std::vector<Joint>& chain, int fromIndex) {
for (size_t i = fromIndex + 1; i < chain.size(); i++) {
int parent = chain[i].parentIndex;
glm::vec3 offset = chain[i].position - chain[parent].position;
offset = chain[parent].rotation * offset;
chain[i].position = chain[parent].position + offset;
}
}
};2.4.3 解析法 IK
对于简单的两关节链(如手臂),可以使用解析法直接求解。
// 两关节 IK(解析法)
// 如:上臂 + 前臂,肩关节 + 肘关节
struct TwoBoneIK {
float upperArmLength;
float forearmLength;
// 求解肩关节和肘关节的角度
bool solve(glm::vec3 shoulderPos, glm::vec3 targetPos,
glm::vec3 poleVector, // 肘部朝向(如前方)
float& shoulderAngle, float& elbowAngle) {
float distance = glm::length(targetPos - shoulderPos);
// 检查目标是否在可达范围内
if (distance > upperArmLength + forearmLength) {
// 目标太远,完全伸展
distance = upperArmLength + forearmLength - 0.001f;
}
if (distance < std::abs(upperArmLength - forearmLength)) {
// 目标太近,无法到达
return false;
}
// 余弦定理求肘关节角度
float cosElbow = (upperArmLength * upperArmLength +
forearmLength * forearmLength -
distance * distance) /
(2.0f * upperArmLength * forearmLength);
elbowAngle = std::acos(glm::clamp(cosElbow, -1.0f, 1.0f));
// 求肩关节角度
float cosShoulder = (upperArmLength * upperArmLength +
distance * distance -
forearmLength * forearmLength) /
(2.0f * upperArmLength * distance);
shoulderAngle = std::acos(glm::clamp(cosShoulder, -1.0f, 1.0f));
return true;
}
};2.5 动画状态机
2.5.1 状态机基础
动画状态机管理角色在不同动画状态之间的切换。例如,角色可能在"待机"、"行走"、"奔跑"、"跳跃"等状态之间切换。
// 动画状态
struct AnimationState {
std::string name;
AnimationClip* clip;
float speed;
bool loop;
};
// 状态转换条件
struct Transition {
std::string fromState;
std::string toState;
float blendDuration; // 混合时间
std::function<bool()> condition; // 转换条件
};
// 动画状态机
class AnimationStateMachine {
public:
void addState(const AnimationState& state) {
states[state.name] = state;
}
void addTransition(const Transition& transition) {
transitions.push_back(transition);
}
void update(float deltaTime) {
// 检查转换条件
for (const auto& trans : transitions) {
if (trans.fromState == currentState && trans.condition()) {
startTransition(trans.toState, trans.blendDuration);
break;
}
}
// 更新当前动画
if (blending) {
blendTime += deltaTime;
float blendFactor = blendTime / blendDuration;
if (blendFactor >= 1.0f) {
blending = false;
currentState = nextState;
currentTime = blendTime;
}
} else {
currentTime += deltaTime * states[currentState].speed;
if (states[currentState].loop) {
currentTime = std::fmod(currentTime,
states[currentState].clip->duration);
}
}
}
void applyToSkeleton(Skeleton& skeleton) const {
if (blending) {
float blendFactor = blendTime / blendDuration;
states[currentState].clip->apply(skeleton, currentTime);
states[nextState].clip->apply(skeleton, blendTime);
skeleton.blend(blendFactor); // 混合两个姿态
} else {
states[currentState].clip->apply(skeleton, currentTime);
}
}
private:
std::map<std::string, AnimationState> states;
std::vector<Transition> transitions;
std::string currentState;
std::string nextState;
float currentTime = 0.0f;
float blendTime = 0.0f;
float blendDuration = 0.0f;
bool blending = false;
void startTransition(const std::string& target, float duration) {
nextState = target;
blendDuration = duration;
blendTime = 0.0f;
blending = true;
}
};2.5.2 动画混合树
混合树(Blend Tree)允许在多个动画之间进行平滑混合,常用于根据速度参数混合行走和奔跑动画。
// 1D 混合树
class BlendTree1D {
public:
struct Entry {
float parameter; // 如速度
AnimationClip* clip;
};
void addEntry(float param, AnimationClip* clip) {
entries.push_back({param, clip});
std::sort(entries.begin(), entries.end(),
[](const Entry& a, const Entry& b) {
return a.parameter < b.parameter;
});
}
void evaluate(float parameter, Skeleton& skeleton, float time) const {
if (entries.empty()) return;
if (entries.size() == 1) {
entries[0].clip->apply(skeleton, time);
return;
}
// 找到参数所在的区间
size_t i = 0;
for (; i < entries.size() - 1; i++) {
if (parameter < entries[i + 1].parameter) break;
}
i = glm::clamp(i, size_t(0), entries.size() - 2);
float t = (parameter - entries[i].parameter) /
(entries[i + 1].parameter - entries[i].parameter);
t = glm::clamp(t, 0.0f, 1.0f);
// 混合两个动画
entries[i].clip->apply(skeleton, time);
Skeleton tempSkeleton = skeleton;
entries[i + 1].clip->apply(tempSkeleton, time);
skeleton.blend(tempSkeleton, t);
}
private:
std::vector<Entry> entries;
};
// 使用示例:根据移动速度混合动画
BlendTree1D locomotionTree;
locomotionTree.addEntry(0.0f, idleClip); // 速度 0:待机
locomotionTree.addEntry(2.0f, walkClip); // 速度 2:行走
locomotionTree.addEntry(5.0f, runClip); // 速度 5:奔跑
locomotionTree.addEntry(8.0f, sprintClip); // 速度 8:冲刺
// 每帧更新
float moveSpeed = character.getMoveSpeed();
locomotionTree.evaluate(moveSpeed, skeleton, animTime);2.6 物理动画基础
2.6.1 刚体动力学
// 简单的刚体模拟
struct RigidBody {
glm::vec3 position;
glm::vec3 velocity;
glm::vec3 acceleration;
float mass;
float restitution; // 弹性系数
bool isStatic;
};
// 显式欧拉积分
void integrateEuler(std::vector<RigidBody>& bodies, float dt) {
glm::vec3 gravity(0, -9.81f, 0);
for (auto& body : bodies) {
if (body.isStatic) continue;
// 更新速度
body.velocity += (gravity + body.acceleration) * dt;
// 更新位置
body.position += body.velocity * dt;
// 地面碰撞
if (body.position.y < 0) {
body.position.y = 0;
body.velocity.y = -body.velocity.y * body.restitution;
// 摩擦
body.velocity.x *= 0.98f;
body.velocity.z *= 0.98f;
}
}
}
// 半隐式欧拉(更稳定)
void integrateSemiImplicitEuler(std::vector<RigidBody>& bodies, float dt) {
glm::vec3 gravity(0, -9.81f, 0);
for (auto& body : bodies) {
if (body.isStatic) continue;
// 先更新速度
body.velocity += (gravity + body.acceleration) * dt;
// 再用新速度更新位置(关键区别)
body.position += body.velocity * dt;
}
}2.6.2 粒子系统
// 粒子系统
struct Particle {
glm::vec3 position;
glm::vec3 velocity;
glm::vec4 color;
float size;
float lifetime;
float maxLifetime;
};
class ParticleSystem {
public:
struct EmitterConfig {
glm::vec3 position;
glm::vec3 emitDirection;
float emitAngle; // 发射锥角
float emitSpeed;
float emitSpeedVariance;
float emitRate; // 每秒发射数
float particleLifetime;
float particleSize;
glm::vec4 startColor;
glm::vec4 endColor;
glm::vec3 gravity;
};
void update(float dt) {
// 发射新粒子
emitAccumulator += config.emitRate * dt;
while (emitAccumulator >= 1.0f) {
emitParticle();
emitAccumulator -= 1.0f;
}
// 更新现有粒子
for (auto& p : particles) {
p.velocity += config.gravity * dt;
p.position += p.velocity * dt;
p.lifetime -= dt;
// 更新颜色和大小
float lifeRatio = 1.0f - p.lifetime / p.maxLifetime;
p.color = glm::mix(config.startColor, config.endColor, lifeRatio);
}
// 移除死亡粒子
particles.erase(
std::remove_if(particles.begin(), particles.end(),
[](const Particle& p) { return p.lifetime <= 0; }),
particles.end());
}
private:
EmitterConfig config;
std::vector<Particle> particles;
float emitAccumulator = 0.0f;
void emitParticle() {
Particle p;
p.position = config.position;
// 在发射锥内随机方向
float angle = config.emitAngle * randomFloat();
float rotation = 2.0f * M_PI * randomFloat();
glm::vec3 dir = glm::rotate(glm::normalize(config.emitDirection),
angle,
glm::vec3(std::cos(rotation), 0,
std::sin(rotation)));
float speed = config.emitSpeed +
(randomFloat() - 0.5f) * config.emitSpeedVariance;
p.velocity = dir * speed;
p.color = config.startColor;
p.size = config.particleSize;
p.lifetime = config.particleLifetime;
p.maxLifetime = config.particleLifetime;
particles.push_back(p);
}
};三、实战案例
3.1 骨骼动画渲染
以下代码展示了一个完整的骨骼动画渲染流程:
class SkeletalAnimationRenderer {
public:
void init(const std::string& modelPath) {
// 加载模型(包含骨架、蒙皮权重、动画片段)
model = loadModel(modelPath);
skeleton = model.skeleton;
animationClip = model.animations[0];
// 设置着色器
shader = loadShader("skinned.vert", "skinned.frag");
}
void update(float deltaTime) {
animTime += deltaTime;
if (loop) {
animTime = std::fmod(animTime, animationClip.duration);
}
// 1. 从动画片段采样骨骼局部变换
animationClip.sample(animTime, skeleton);
// 2. 计算骨骼全局变换
skeleton.updateBoneMatrices();
// 3. 计算蒙皮矩阵
skinMatrices = skeleton.getSkinMatrices();
}
void render(const glm::mat4& view, const glm::mat4& projection) {
glUseProgram(shader);
// 上传 MVP 矩阵
glm::mat4 modelMatrix = glm::mat4(1.0f);
glUniformMatrix4fv(glGetUniformLocation(shader, "uModel"),
1, GL_FALSE, glm::value_ptr(modelMatrix));
glUniformMatrix4fv(glGetUniformLocation(shader, "uView"),
1, GL_FALSE, glm::value_ptr(view));
glUniformMatrix4fv(glGetUniformLocation(shader, "uProjection"),
1, GL_FALSE, glm::value_ptr(projection));
// 上传骨骼矩阵
GLint boneLoc = glGetUniformLocation(shader, "uBoneMatrices");
glUniformMatrix4fv(boneLoc, skinMatrices.size(), GL_FALSE,
glm::value_ptr(skinMatrices[0]));
// 绑定纹理
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, model.diffuseTexture);
// 渲染
glBindVertexArray(model.vao);
glDrawElements(GL_TRIANGLES, model.indexCount, GL_UNSIGNED_INT, 0);
}
private:
Model model;
Skeleton skeleton;
AnimationClip animationClip;
GLuint shader;
std::vector<glm::mat4> skinMatrices;
float animTime = 0.0f;
bool loop = true;
};3.2 动画混合演示
// 动画状态机使用示例
class CharacterController {
public:
void init() {
// 注册动画状态
stateMachine.addState({"Idle", idleClip, 1.0f, true});
stateMachine.addState({"Walk", walkClip, 1.0f, true});
stateMachine.addState({"Run", runClip, 1.0f, true});
stateMachine.addState({"Jump", jumpClip, 1.0f, false});
// 注册转换
stateMachine.addTransition({
"Idle", "Walk", 0.3f,
[this]() { return moveSpeed > 0.1f && isGrounded; }
});
stateMachine.addTransition({
"Walk", "Idle", 0.3f,
[this]() { return moveSpeed < 0.1f; }
});
stateMachine.addTransition({
"Walk", "Run", 0.2f,
[this]() { return moveSpeed > 3.0f; }
});
stateMachine.addTransition({
"Run", "Walk", 0.2f,
[this]() { return moveSpeed <= 3.0f && moveSpeed > 0.1f; }
});
stateMachine.addTransition({
"Walk", "Jump", 0.1f,
[this]() { return jumpPressed && isGrounded; }
});
stateMachine.addTransition({
"Jump", "Idle", 0.2f,
[this]() { return isGrounded && animTime > jumpClip->duration * 0.9f; }
});
stateMachine.setState("Idle");
}
void update(float deltaTime) {
stateMachine.update(deltaTime);
stateMachine.applyToSkeleton(skeleton);
skeleton.updateBoneMatrices();
}
float moveSpeed = 0.0f;
bool isGrounded = true;
bool jumpPressed = false;
private:
AnimationStateMachine stateMachine;
Skeleton skeleton;
};四、本章小结
本章全面讲解了计算机动画的核心技术和理论:
关键帧动画通过定义关键时间点上的值,利用插值自动生成中间帧。缓动函数和样条插值使运动更加自然流畅。
骨骼动画使用层次化的骨架驱动模型变形。蒙皮定义了顶点与骨骼的权重关系,GPU 蒙皮利用并行计算实现高效渲染。
逆向运动学(IK)从期望的末端位置反算关节角度。CCD 算法适用于任意长度的骨骼链,解析法在两关节情况下效率最高。
动画状态机管理角色在不同动画状态之间的切换,混合树支持根据参数在多个动画间平滑过渡。
物理动画通过数值积分模拟真实的物理运动。半隐式欧拉比显式欧拉更稳定,粒子系统通过大量简单粒子的集体行为产生复杂效果。
动作捕捉从真人表演获取高质量动画数据,是现代游戏和影视动画的主要制作方式。
五、思考与练习
思考题
为什么四元数 SLERP 比欧拉角线性插值更适合旋转动画?在什么情况下差异最明显?
蒙皮权重为什么必须归一化(和为 1)?如果某个顶点的权重和为 0.8,渲染时会出现什么问题?
CCD 算法的收敛性如何保证?在什么情况下 CCD 可能不收敛或收敛到错误解?
动画混合的持续时间(blend duration)如何选择?太长或太短分别有什么问题?
物理模拟中的时间步长对稳定性有什么影响?为什么固定时间步长比可变时间步长更稳定?
编程练习
缓动函数可视化:实现一个程序,同时显示所有常用缓动函数的曲线图(时间-值关系),并展示一个球按照各缓动函数运动的效果。
骨骼动画渲染:加载一个带骨骼动画的模型(如 glTF 格式),实现 GPU 蒙皮渲染。支持播放、暂停、速度调节。
IK 系统:实现 CCD IK 算法,创建一个可拖拽目标的 IK 演示。支持关节角度限制,使骨骼只能在一定范围内弯曲。
粒子系统:实现一个可配置的粒子系统,支持多种发射模式(点发射、面发射)、力场(重力、风力)和颜色渐变。