第七篇:动画基础:requestAnimationFrame循环

第七篇:动画基础:requestAnimationFrame循环

引言

动画是赋予3D场景生命力的关键。Three.js提供了多种动画实现方式,从基础的旋转动画到复杂的骨骼系统。本文将深入解析动画核心原理,并通过Vue3实现一个功能完备的动画编辑器,让你掌握3D动态效果的实现技巧。


在这里插入图片描述

1. 动画循环原理
1.1 requestAnimationFrame

浏览器原生动画API,以60FPS(约16.7ms/帧)为基准:

function animate() {
  // 更新动画逻辑
  updateScene();
  
  // 渲染场景
  renderer.render(scene, camera);
  
  // 循环调用
  requestAnimationFrame(animate);
}
animate();
1.2 动画时间控制
const clock = new THREE.Clock();
let deltaTime = 0;

function animate() {
  deltaTime = clock.getDelta(); // 获取上一帧耗时
  
  // 基于时间的动画(帧率无关)
  cube.rotation.y += 0.5 * deltaTime;
  
  requestAnimationFrame(animate);
}
1.3 性能优化策略
graph TD
    A[动画循环] --> B{场景变化?}
    B -->|无变化| C[跳过渲染]
    B -->|有变化| D[执行渲染]

2. 基础动画实现
2.1 旋转动画
function animate() {
  cube.rotation.x += 0.01;
  cube.rotation.y += 0.02;
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}
2.2 位移动画
const startPos = new THREE.Vector3(0, 0, 0);
const endPos = new THREE.Vector3(5, 3, -2);
const duration = 3; // 秒

function animateMove() {
  const elapsed = clock.getElapsedTime();
  const progress = Math.min(elapsed / duration, 1);
  
  // 线性插值
  cube.position.lerpVectors(startPos, endPos, progress);
  
  if (progress < 1) {
    requestAnimationFrame(animateMove);
  }
}
2.3 缩放动画(脉动效果)
function pulse() {
  const scale = 1 + Math.sin(clock.getElapsedTime() * 2) * 0.2;
  cube.scale.set(scale, scale, scale);
  requestAnimationFrame(pulse);
}

3. 高级动画库:GSAP
3.1 安装与配置
npm install gsap
3.2 基础动画示例
<script setup>
import gsap from 'gsap';

onMounted(() => {
  // 位移动画
  gsap.to(cube.position, {
    x: 5,
    duration: 2,
    repeat: -1, // 无限重复
    yoyo: true, // 往返运动
    ease: "power2.inOut"
  });
  
  // 颜色渐变动画
  gsap.to(cube.material.color, {
    r: 1, g: 0, b: 0, // 红色
    duration: 3,
    onComplete: () => console.log("颜色变化完成")
  });
});
</script>
3.3 动画时间线
const timeline = gsap.timeline({
  repeat: -1,
  repeatDelay: 1
});

timeline
  .to(cube.position, { x: 5, duration: 2 })
  .to(cube.scale, { x: 2, y: 2, z: 2, duration: 1 }, "-=0.5") // 与前一动画重叠0.5秒
  .to(cube.rotation, { y: Math.PI * 2, duration: 1.5 }, "+=0.5"); // 延迟0.5秒

4. 骨骼动画系统
4.1 加载GLTF骨骼模型
<script setup>
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

const loader = new GLTFLoader();
loader.load('model.gltf', (gltf) => {
  const model = gltf.scene;
  scene.add(model);
  
  // 获取动画混合器
  mixer = new THREE.AnimationMixer(model);
  
  // 播放所有动画
  gltf.animations.forEach((clip) => {
    mixer.clipAction(clip).play();
  });
});
</script>
4.2 动画混合器更新
const mixer = null;

function animate() {
  const delta = clock.getDelta();
  if (mixer) mixer.update(delta);
  requestAnimationFrame(animate);
}
4.3 动画控制
// 获取动画动作
const walkAction = mixer.clipAction(gltf.animations[0]);
const runAction = mixer.clipAction(gltf.animations[1]);

// 配置动画
walkAction
  .setEffectiveTimeScale(0.8) // 80%速度
  .setLoop(THREE.LoopRepeat)
  .play();

// 动画过渡
function transitionToRun() {
  gsap.to(walkAction, {
    timeScale: 0,
    duration: 0.3,
    onComplete: () => walkAction.stop()
  });
  
  runAction
    .setEffectiveTimeScale(1.2)
    .fadeIn(0.3)
    .play();
}

5. 变形动画(Morph Targets)
5.1 创建变形目标
const geometry = new THREE.BoxGeometry(1, 1, 1);

// 添加变形目标(缩放效果)
geometry.morphAttributes.position = [
  new Float32Array([
    0,0,0,  0,0,0,  0,0,0,  // 原始顶点
    // 目标形状:Y方向拉伸
    0,2,0,  0,2,0,  0,2,0
  ])
];

const material = new THREE.MeshBasicMaterial({
  morphTargets: true,
  wireframe: true
});

const cube = new THREE.Mesh(geometry, material);
5.2 控制变形权重
// 设置权重(0-1)
cube.morphTargetInfluences[0] = 0.5;

// 动画变化
gsap.to(cube.morphTargetInfluences, {
  0: 1,
  duration: 2,
  yoyo: true,
  repeat: -1
});

6. Vue3实战:动画编辑器
6.1 项目结构
src/
  ├── components/
  │    ├── AnimationEditor.vue  // 主编辑器
  │    ├── TimelineControl.vue  // 时间线
  │    ├── KeyframeEditor.vue   // 关键帧编辑
  │    └── AnimationPreview.vue // 3D预览
  └── App.vue
6.2 动画编辑器核心
<!-- AnimationEditor.vue -->
<script setup>
import { ref, reactive } from 'vue';
import gsap from 'gsap';

const animations = ref([]);
const currentAnim = ref(null);
const timeline = ref(null);

// 动画类型选项
const ANIM_TYPES = {
  POSITION: 'position',
  ROTATION: 'rotation',
  SCALE: 'scale',
  COLOR: 'color',
  MORPH: 'morph'
};

// 添加新动画
function addAnimation(type, target) {
  const anim = {
    id: Date.now(),
    type,
    target,
    duration: 2,
    ease: 'power2.inOut',
    keyframes: [
      { time: 0, value: getCurrentValue(target, type) },
      { time: 2, value: getDefaultEndValue(type) }
    ]
  };
  
  animations.value.push(anim);
  currentAnim.value = anim;
}

// 应用动画到场景
function applyAnimation() {
  if (!currentAnim.value) return;
  
  timeline.value && timeline.value.kill();
  timeline.value = gsap.timeline();
  
  animations.value.forEach(anim => {
    const target = scene.getObjectById(anim.target.id);
    if (!target) return;
    
    const vars = {
      duration: anim.duration,
      ease: anim.ease
    };
    
    switch(anim.type) {
      case ANIM_TYPES.POSITION:
        timeline.value.to(target.position, {
          x: anim.keyframes[1].value.x,
          y: anim.keyframes[1].value.y,
          z: anim.keyframes[1].value.z,
          ...vars
        }, 0);
        break;
        
      case ANIM_TYPES.COLOR:
        timeline.value.to(target.material.color, {
          r: anim.keyframes[1].value.r,
          g: anim.keyframes[1].value.g,
          b: anim.keyframes[1].value.b,
          ...vars
        }, 0);
        break;
        
      // 其他类型处理...
    }
  });
}
</script>

<template>
  <div class="animation-editor">
    <div class="animation-list">
      <button v-for="anim in animations" 
              :class="{ active: anim === currentAnim }"
              @click="currentAnim = anim">
        {{ anim.type }} ({{ anim.duration }}s)
      </button>
      <button @click="addAnimation(ANIM_TYPES.POSITION, selectedObject)">
        + 添加动画
      </button>
    </div>
    
    <div v-if="currentAnim" class="animation-detail">
      <KeyframeEditor :animation="currentAnim" />
      <div class="controls">
        <button @click="applyAnimation">播放</button>
        <button @click="timeline.pause()">暂停</button>
        <button @click="timeline.seek(0)">重置</button>
      </div>
    </div>
  </div>
</template>
6.3 关键帧编辑器
<!-- KeyframeEditor.vue -->
<script setup>
import { computed } from 'vue';

const props = defineProps(['animation']);
const emit = defineEmits(['update']);

// 当前选中的关键帧
const selectedKeyframe = ref(0);

// 添加关键帧
function addKeyframe() {
  const newTime = prompt('输入时间(秒):');
  if (newTime && !isNaN(newTime)) {
    const newFrame = {
      time: parseFloat(newTime),
      value: getCurrentValue(props.animation.target, props.animation.type)
    };
    props.animation.keyframes.push(newFrame);
    sortKeyframes();
  }
}

// 删除关键帧
function deleteKeyframe(index) {
  if (props.animation.keyframes.length > 2) {
    props.animation.keyframes.splice(index, 1);
  }
}

// 按时间排序
function sortKeyframes() {
  props.animation.keyframes.sort((a, b) => a.time - b.time);
  props.animation.duration = Math.max(...props.animation.keyframes.map(k => k.time));
}

// 更新关键帧值
function updateKeyframeValue(index, newValue) {
  props.animation.keyframes[index].value = { ...newValue };
  emit('update');
}
</script>

<template>
  <div class="keyframe-editor">
    <div class="keyframe-list">
      <div v-for="(kf, i) in animation.keyframes" 
           :class="{ selected: i === selectedKeyframe }"
           @click="selectedKeyframe = i">
        <span>时间: {{ kf.time.toFixed(2) }}s</span>
        <button @click.stop="deleteKeyframe(i)">×</button>
      </div>
      <button @click="addKeyframe">+ 添加关键帧</button>
    </div>
    
    <div v-if="selectedKeyframe !== null" class="keyframe-detail">
      <ParamRange 
        label="时间" 
        v-model="animation.keyframes[selectedKeyframe].time"
        :min="0" :max="animation.duration" :step="0.1"
      />
      
      <!-- 根据动画类型显示不同控件 -->
      <template v-if="animation.type === 'position'">
        <VectorControl 
          v-model="animation.keyframes[selectedKeyframe].value"
          :labels="['X', 'Y', 'Z']"
          @change="updateKeyframeValue(selectedKeyframe, $event)"
        />
      </template>
      
      <template v-else-if="animation.type === 'color'">
        <ColorPicker 
          v-model="animation.keyframes[selectedKeyframe].value"
          @change="updateKeyframeValue(selectedKeyframe, $event)"
        />
      </template>
    </div>
  </div>
</template>
6.4 动画预览组件
<!-- AnimationPreview.vue -->
<script setup>
import { onMounted, ref } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const canvasRef = ref(null);
const scene = new THREE.Scene();
const clock = new THREE.Clock();

// 初始化场景
onMounted(() => {
  const renderer = new THREE.WebGLRenderer({ canvas: canvasRef.value });
  const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000);
  camera.position.z = 5;
  
  const controls = new OrbitControls(camera, renderer.domElement);
  
  // 添加测试物体
  const cube = new THREE.Mesh(
    new THREE.BoxGeometry(),
    new THREE.MeshStandardMaterial({ color: 0x00ff00 })
  );
  scene.add(cube);
  
  // 动画循环
  const animate = () => {
    requestAnimationFrame(animate);
    controls.update();
    renderer.render(scene, camera);
  };
  animate();
});

// 接收外部动画指令
function playAnimation(timeline) {
  timeline.seek(0);
  timeline.play();
}
</script>

<template>
  <div class="animation-preview">
    <canvas ref="canvasRef" width="600" height="400"></canvas>
  </div>
</template>

7. 物理动画与碰撞
7.1 集成Cannon.js
npm install cannon
<script setup>
import * as CANNON from 'cannon';
import * as THREE from 'three';

// 创建物理世界
const world = new CANNON.World();
world.gravity.set(0, -9.82, 0);

// 创建物理物体
const sphereBody = new CANNON.Body({
  mass: 5,
  shape: new CANNON.Sphere(0.5)
});
sphereBody.position.set(0, 10, 0);
world.addBody(sphereBody);

// 同步Three.js物体
const sphereMesh = new THREE.Mesh(
  new THREE.SphereGeometry(0.5),
  new THREE.MeshStandardMaterial()
);
scene.add(sphereMesh);

// 物理更新循环
function physicsStep() {
  world.step(1/60); // 60FPS更新
  sphereMesh.position.copy(sphereBody.position);
  sphereMesh.quaternion.copy(sphereBody.quaternion);
}
7.2 碰撞检测
// 碰撞事件监听
sphereBody.addEventListener('collide', (e) => {
  console.log('碰撞强度:', e.contact.getImpactVelocityAlongNormal());
});

// 射线检测
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();

function checkIntersection(x, y) {
  mouse.set(
    (x / window.innerWidth) * 2 - 1,
    -(y / window.innerHeight) * 2 + 1
  );
  
  raycaster.setFromCamera(mouse, camera);
  const intersects = raycaster.intersectObjects(scene.children);
  
  if (intersects.length > 0) {
    // 处理点击物体
  }
}

8. 动画状态机
8.1 状态机实现
class AnimationStateMachine {
  constructor() {
    this.states = {};
    this.currentState = null;
  }
  
  addState(name, onEnter, onUpdate, onExit) {
    this.states[name] = { onEnter, onUpdate, onExit };
  }
  
  setState(name) {
    if (this.currentState && this.states[this.currentState].onExit) {
      this.states[this.currentState].onExit();
    }
    
    this.currentState = name;
    
    if (this.states[name].onEnter) {
      this.states[name].onEnter();
    }
  }
  
  update(delta) {
    if (this.currentState && this.states[this.currentState].onUpdate) {
      this.states[this.currentState].onUpdate(delta);
    }
  }
}

// 角色动画状态机
const characterAnim = new AnimationStateMachine();

characterAnim.addState('idle', 
  () => idleAction.play(),
  (delta) => mixer.update(delta),
  () => idleAction.stop()
);

characterAnim.addState('walk', 
  () => walkAction.play(),
  (delta) => mixer.update(delta),
  () => walkAction.stop()
);

// 切换状态
characterAnim.setState('walk');
8.2 状态过渡
// 混合状态
characterAnim.addState('walk_to_run', () => {
  walkAction.fadeOut(0.2);
  runAction.fadeIn(0.2).play();
}, (delta) => {
  mixer.update(delta);
}, () => {
  runAction.stop();
});

9. 性能优化技巧
9.1 动画帧率控制
const targetFPS = 30;
const interval = 1000 / targetFPS;
let lastTime = 0;

function animate(timestamp) {
  const deltaTime = timestamp - lastTime;
  
  if (deltaTime > interval) {
    // 执行更新
    updateAnimations(deltaTime / 1000);
    renderer.render(scene, camera);
    lastTime = timestamp;
  }
  
  requestAnimationFrame(animate);
}
9.2 不可见物体暂停
function animate() {
  // 检查物体是否在视锥内
  if (camera.frustumIntersectsObject(cube)) {
    cube.rotation.y += 0.01;
  }
  
  requestAnimationFrame(animate);
}
9.3 Web Workers 计算
// 主线程
const worker = new Worker('physics-worker.js');

function updatePhysics() {
  worker.postMessage({
    type: 'step',
    dt: clock.getDelta(),
    bodies: getBodyStates()
  });
  
  worker.onmessage = (e) => {
    applyBodyStates(e.data);
  };
}

// physics-worker.js
self.onmessage = (e) => {
  if (e.data.type === 'step') {
    // 执行物理计算
    world.step(e.data.dt);
    
    // 返回结果
    postMessage(getBodyStates());
  }
};

10. 常见问题解答

Q1:动画卡顿不流畅怎么办?

  1. 检查FPS(Stats.js)
  2. 减少场景复杂度(几何体/光源/阴影)
  3. 使用requestAnimationFrame时间戳
  4. 避免在动画循环中创建新对象

Q2:如何实现角色行走动画?

// 混合行走和空闲动画
const walkWeight = controls.speed / maxSpeed;
idleAction.setEffectiveWeight(1 - walkWeight);
walkAction.setEffectiveWeight(walkWeight);

Q3:动画结束后如何释放资源?

// GSAP动画
tl.eventCallback("onComplete", () => {
  cube.geometry.dispose();
  cube.material.dispose();
  scene.remove(cube);
});

// 骨骼动画
mixer.addEventListener('finished', (e) => {
  mixer.uncacheRoot(e.action.getRoot());
});

11. 总结

通过本文,你已掌握:

  1. requestAnimationFrame动画循环原理
  2. GSAP高级动画库应用
  3. 骨骼动画与变形动画技术
  4. 物理动画与碰撞检测实现
  5. 动画状态机管理系统
  6. Vue3实现功能完备的动画编辑器
  7. 动画性能优化策略

核心原理:Three.js动画系统基于时间驱动,通过更新对象属性(位置/旋转/缩放)或变形权重,结合渲染循环实现动态效果。


下一篇预告

第八篇:交互入门:鼠标拾取物体
你将学习:

  • Raycaster射线检测原理
  • 鼠标点击/悬停交互实现
  • 3D物体拖拽与变换控制
  • 碰撞检测与物理交互
  • GUI控制面板集成
  • Vue3实现交互式3D展厅

准备好让你的3D场景响应用户操作了吗?让我们进入交互式3D开发的世界!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

二川bro

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值