8计算机图形学原理及实践:基础方法(第3版)

动画

运动与时间

关键帧动画骨骼动画物理动画

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第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.3ms

2.1.2 动画循环

cpp
// 基本的动画更新循环
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 关键帧与插值

关键帧动画的核心思想是:只定义关键时间点上的值(关键帧),中间帧通过插值自动生成。

cpp
// 关键帧定义
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)

线性插值产生机械感的匀速运动。缓动函数通过改变插值的速度曲线,产生更自然的运动效果。

cpp
// 常用缓动函数
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 样条)代替分段线性插值。

cpp
// 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)组成,形成树状层次结构。

cpp
// 骨骼定义
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 个骨骼影响,每个骨骼有一个权重值。

cpp
// 带骨骼权重的顶点
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 蒙皮。

glsl
// 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;
}
cpp
// 上传骨骼矩阵到 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 求解算法之一。它从末端骨骼开始,逐个调整每个关节的旋转,使末端尽量接近目标。

cpp
// 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

对于简单的两关节链(如手臂),可以使用解析法直接求解。

cpp
// 两关节 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 状态机基础

动画状态机管理角色在不同动画状态之间的切换。例如,角色可能在"待机"、"行走"、"奔跑"、"跳跃"等状态之间切换。

cpp
// 动画状态
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)允许在多个动画之间进行平滑混合,常用于根据速度参数混合行走和奔跑动画。

cpp
// 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 刚体动力学

cpp
// 简单的刚体模拟
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 粒子系统

cpp
// 粒子系统
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 骨骼动画渲染

以下代码展示了一个完整的骨骼动画渲染流程:

cpp
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 动画混合演示

cpp
// 动画状态机使用示例
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 演示。支持关节角度限制,使骨骼只能在一定范围内弯曲。

粒子系统:实现一个可配置的粒子系统,支持多种发射模式(点发射、面发射)、力场(重力、风力)和颜色渐变。


8/8动画
0%