动画与交互
动画让 3D 场景充满活力,交互让用户能够参与其中。Three.js 提供了多种动画实现方式和交互机制,本章将详细介绍如何让你的 3D 应用动起来并响应用户操作。
动画基础
动画的本质是在每一帧稍微改变物体的状态(位置、旋转、缩放等),利用人眼的视觉暂留效应产生连续运动的错觉。
渲染循环
Three.js 使用 requestAnimationFrame 创建渲染循环:
function animate() {
requestAnimationFrame(animate);
// 更新物体状态
mesh.rotation.y += 0.01;
// 渲染场景
renderer.render(scene, camera);
}
animate();
requestAnimationFrame 会在浏览器准备好绘制下一帧时调用回调函数,通常是每秒 60 次(60 FPS)。它比 setInterval 更高效,因为浏览器会自动优化调用时机,在页面不可见时暂停执行。
使用 Clock
对于需要精确计时的动画,使用 Clock 类:
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
// 获取时间信息
const delta = clock.getDelta(); // 距上一帧的时间(秒)
const elapsed = clock.getElapsedTime(); // 总时间(秒)
// 基于时间的动画(帧率无关)
mesh.rotation.y = elapsed;
renderer.render(scene, camera);
}
使用 delta 时间可以确保动画在不同帧率下保持一致的速度:
const speed = 1; // 每秒旋转的速度
mesh.rotation.y += speed * delta;
基础动画
旋转动画
// 持续旋转
function animate() {
requestAnimationFrame(animate);
mesh.rotation.y += 0.01;
renderer.render(scene, camera);
}
// 来回旋转
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
mesh.rotation.y = Math.sin(t) * Math.PI;
renderer.render(scene, camera);
}
位置动画
// 圆周运动
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
const radius = 3;
mesh.position.x = Math.cos(t) * radius;
mesh.position.z = Math.sin(t) * radius;
renderer.render(scene, camera);
}
// 弹跳效果
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
mesh.position.y = Math.abs(Math.sin(t * 2)) * 2;
renderer.render(scene, camera);
}
缩放动画
// 呼吸效果
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
const scale = 1 + Math.sin(t * 2) * 0.1;
mesh.scale.set(scale, scale, scale);
renderer.render(scene, camera);
}
颜色动画
// 颜色渐变
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
const color = new THREE.Color();
color.setHSL((t * 0.1) % 1, 0.7, 0.5);
mesh.material.color = color;
renderer.render(scene, camera);
}
动画系统
Three.js 提供了完整的动画系统,支持关键帧动画、骨骼动画、变形动画等。
关键帧动画
关键帧动画通过定义关键帧,自动计算中间状态:
// 创建动画混合
const mixer = new THREE.AnimationMixer(mesh);
// 创建位置关键帧轨道
const positionKF = new THREE.VectorKeyframeTrack(
'.position', // 属性路径
[0, 1, 2], // 时间点
[0, 0, 0, 2, 2, 0, 0, 0, 0] // 值(x, y, z)
);
// 创建旋转关键帧轨道
const rotationKF = new THREE.QuaternionKeyframeTrack(
'.quaternion',
[0, 1, 2],
[0, 0, 0, 1, 0, 0.707, 0, 0.707, 0, 0, 0, 1]
);
// 创建动画剪辑
const clip = new THREE.AnimationClip('action', 2, [positionKF, rotationKF]);
// 播放动画
const action = mixer.clipAction(clip);
action.play();
// 在渲染循环中更新混合器
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
mixer.update(delta);
renderer.render(scene, camera);
}
动画控制
// 播放
action.play();
// 暂停
action.paused = true;
// 停止
action.stop();
// 设置播放速度
action.timeScale = 2; // 两倍速
// 设置循环模式
action.loop = THREE.LoopRepeat; // 循环播放
action.loop = THREE.LoopOnce; // 播放一次
action.loop = THREE.LoopPingPong; // 来回播放
// 设置循环次数
action.repetitions = 3;
// 设置权重(用于混合多个动画)
action.weight = 0.5;
骨骼动画
骨骼动画用于角色动画,通常从外部模型加载:
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const loader = new GLTFLoader();
loader.load('model.glb', (gltf) => {
const model = gltf.scene;
scene.add(model);
// 创建动画混合器
const mixer = new THREE.AnimationMixer(model);
// 播放所有动画
gltf.animations.forEach((clip) => {
const action = mixer.clipAction(clip);
action.play();
});
// 存储混合器以便更新
mixers.push(mixer);
});
变形动画
变形动画通过在多个形状之间插值实现:
// 创建带有变形目标的几何体
const geometry = new THREE.BoxGeometry(1, 1, 1);
// 创建变形目标
const morphTargets = [];
for (let i = 0; i < 8; i++) {
const target = new THREE.BoxGeometry(1, 1, 1);
// 修改顶点位置...
morphTargets.push(target);
}
// 添加变形目标到几何体
geometry.morphAttributes.position = morphTargets.map(t => t.attributes.position);
// 创建材质
const material = new THREE.MeshStandardMaterial({
color: 0x00ff00,
morphTargets: true
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
// 动画变形
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
mesh.morphTargetInfluences[0] = (Math.sin(t) + 1) / 2;
renderer.render(scene, camera);
}
缓动函数
缓动函数让动画更自然,Three.js 内置了一些缓动函数:
import { Easing } from 'three';
// 常用缓动函数
Easing.Linear.None;
Easing.Quadratic.In;
Easing.Quadratic.Out;
Easing.Quadratic.InOut;
Easing.Cubic.In;
Easing.Cubic.Out;
Easing.Cubic.InOut;
Easing.Elastic.In;
Easing.Elastic.Out;
Easing.Bounce.In;
Easing.Bounce.Out;
也可以自己实现缓动函数:
// 自定义缓动函数
function easeInOutQuad(t) {
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}
// 使用缓动函数
let progress = 0;
function animate() {
requestAnimationFrame(animate);
progress += 0.01;
if (progress > 1) progress = 0;
const easedProgress = easeInOutQuad(progress);
mesh.position.y = easedProgress * 5;
renderer.render(scene, camera);
}
交互系统
Three.js 提供了多种交互方式,从简单的鼠标点击到复杂的手势控制。
鼠标交互
使用 Raycaster 检测鼠标与 3D 物体的交互:
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
// 监听鼠标移动
window.addEventListener('mousemove', (event) => {
// 将鼠标位置转换为归一化设备坐标
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
});
function animate() {
requestAnimationFrame(animate);
// 更新射线
raycaster.setFromCamera(mouse, camera);
// 检测相交的物体
const intersects = raycaster.intersectObjects(scene.children);
// 处理相交结果
for (let i = 0; i < intersects.length; i++) {
intersects[i].object.material.color.set(0xff0000);
}
renderer.render(scene, camera);
}
点击选择
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let selectedObject = null;
window.addEventListener('click', (event) => {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children);
if (intersects.length > 0) {
// 取消之前的选择
if (selectedObject) {
selectedObject.material.emissive.set(0x000000);
}
// 选择新物体
selectedObject = intersects[0].object;
selectedObject.material.emissive.set(0x333333);
}
});
悬停效果
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let hoveredObject = null;
window.addEventListener('mousemove', (event) => {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
});
function animate() {
requestAnimationFrame(animate);
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children);
// 恢复之前悬停物体的状态
if (hoveredObject && (!intersects.length || intersects[0].object !== hoveredObject)) {
hoveredObject.scale.set(1, 1, 1);
hoveredObject = null;
}
// 设置新悬停物体的状态
if (intersects.length > 0 && intersects[0].object !== hoveredObject) {
hoveredObject = intersects[0].object;
hoveredObject.scale.set(1.1, 1.1, 1.1);
}
renderer.render(scene, camera);
}
拖拽交互
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
const intersection = new THREE.Vector3();
let isDragging = false;
let draggedObject = null;
let offset = new THREE.Vector3();
window.addEventListener('mousedown', (event) => {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children);
if (intersects.length > 0) {
isDragging = true;
draggedObject = intersects[0].object;
// 计算偏移量
plane.setFromNormalAndCoplanarPoint(
camera.getWorldDirection(plane.normal),
draggedObject.position
);
raycaster.ray.intersectPlane(plane, intersection);
offset.copy(intersection).sub(draggedObject.position);
}
});
window.addEventListener('mousemove', (event) => {
if (!isDragging) return;
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
raycaster.ray.intersectPlane(plane, intersection);
draggedObject.position.copy(intersection.sub(offset));
});
window.addEventListener('mouseup', () => {
isDragging = false;
draggedObject = null;
});
键盘交互
const keys = {};
window.addEventListener('keydown', (event) => {
keys[event.code] = true;
});
window.addEventListener('keyup', (event) => {
keys[event.code] = false;
});
function animate() {
requestAnimationFrame(animate);
const speed = 0.1;
if (keys['KeyW']) mesh.position.z -= speed;
if (keys['KeyS']) mesh.position.z += speed;
if (keys['KeyA']) mesh.position.x -= speed;
if (keys['KeyD']) mesh.position.x += speed;
renderer.render(scene, camera);
}
触摸交互
// 单指旋转
let previousTouch = null;
renderer.domElement.addEventListener('touchstart', (event) => {
if (event.touches.length === 1) {
previousTouch = {
x: event.touches[0].clientX,
y: event.touches[0].clientY
};
}
});
renderer.domElement.addEventListener('touchmove', (event) => {
if (event.touches.length === 1 && previousTouch) {
const touch = event.touches[0];
const deltaX = touch.clientX - previousTouch.x;
const deltaY = touch.clientY - previousTouch.y;
mesh.rotation.y += deltaX * 0.01;
mesh.rotation.x += deltaY * 0.01;
previousTouch = {
x: touch.clientX,
y: touch.clientY
};
}
});
renderer.domElement.addEventListener('touchend', () => {
previousTouch = null;
});
控制器
Three.js 提供了多种控制器,简化交互实现。
OrbitControls
轨道控制器允许用户旋转、缩放、平移视角:
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const controls = new OrbitControls(camera, renderer.domElement);
// 启用阻尼(惯性)
controls.enableDamping = true;
controls.dampingFactor = 0.05;
// 自动旋转
controls.autoRotate = true;
controls.autoRotateSpeed = 2;
// 限制缩放范围
controls.minDistance = 2;
controls.maxDistance = 20;
// 限制垂直旋转角度
controls.minPolarAngle = 0;
controls.maxPolarAngle = Math.PI / 2;
// 限制水平旋转角度
controls.minAzimuthAngle = -Math.PI / 2;
controls.maxAzimuthAngle = Math.PI / 2;
// 禁用平移
controls.enablePan = false;
// 禁用缩放
controls.enableZoom = false;
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
TransformControls
变换控制器允许用户在场景中直接移动物体:
import { TransformControls } from 'three/addons/controls/TransformControls.js';
const transformControls = new TransformControls(camera, renderer.domElement);
scene.add(transformControls);
// 附加到物体
transformControls.attach(mesh);
// 切换模式
transformControls.setMode('translate'); // 平移
transformControls.setMode('rotate'); // 旋转
transformControls.setMode('scale'); // 缩放
// 监听变化
transformControls.addEventListener('change', () => {
console.log('Object changed');
});
// 在拖拽时禁用 OrbitControls
transformControls.addEventListener('dragging-changed', (event) => {
controls.enabled = !event.value;
});
PointerLockControls
指针锁定控制器用于第一人称视角控制:
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
const controls = new PointerLockControls(camera, document.body);
// 点击锁定指针
document.addEventListener('click', () => {
controls.lock();
});
// 监听锁定状态
controls.addEventListener('lock', () => {
console.log('Pointer locked');
});
controls.addEventListener('unlock', () => {
console.log('Pointer unlocked');
});
// 移动
const velocity = new THREE.Vector3();
const direction = new THREE.Vector3();
function animate() {
requestAnimationFrame(animate);
if (controls.isLocked) {
direction.z = Number(keys['KeyW']) - Number(keys['KeyS']);
direction.x = Number(keys['KeyD']) - Number(keys['KeyA']);
direction.normalize();
velocity.z = direction.z * speed;
velocity.x = direction.x * speed;
controls.moveRight(velocity.x);
controls.moveForward(velocity.z);
}
renderer.render(scene, camera);
}
完整示例
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// 场景设置
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(5, 5, 5);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
// 创建多个可交互的立方体
const cubes = [];
const colors = [0xff6b6b, 0x4ecdc4, 0xffe66d, 0x95e1d3, 0xf38181, 0xaa96da];
for (let i = 0; i < 6; i++) {
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({
color: colors[i],
metalness: 0.3,
roughness: 0.4
});
const cube = new THREE.Mesh(geometry, material);
cube.position.x = (i % 3 - 1) * 2.5;
cube.position.z = (Math.floor(i / 3) - 0.5) * 2.5;
cube.position.y = 0.5;
cube.castShadow = true;
cube.receiveShadow = true;
cube.userData.originalColor = colors[i];
cube.userData.originalY = 0.5;
scene.add(cube);
cubes.push(cube);
}
// 地面
const groundGeometry = new THREE.PlaneGeometry(15, 15);
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x333344 });
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// 光源
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(5, 10, 5);
directionalLight.castShadow = true;
scene.add(directionalLight);
// 交互
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let hoveredCube = null;
window.addEventListener('mousemove', (event) => {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
});
window.addEventListener('click', () => {
if (hoveredCube) {
// 随机改变颜色
hoveredCube.material.color.setHex(Math.random() * 0xffffff);
}
});
// 动画
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const elapsed = clock.getElapsedTime();
// 更新射线
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(cubes);
// 重置之前悬停的立方体
if (hoveredCube && (!intersects.length || intersects[0].object !== hoveredCube)) {
hoveredCube.scale.set(1, 1, 1);
hoveredCube = null;
}
// 设置新悬停的立方体
if (intersects.length > 0) {
hoveredCube = intersects[0].object;
hoveredCube.scale.set(1.1, 1.1, 1.1);
}
// 所有立方体轻微旋转和浮动
cubes.forEach((cube, i) => {
cube.rotation.y = elapsed * 0.5 + i;
cube.position.y = cube.userData.originalY + Math.sin(elapsed * 2 + i) * 0.1;
});
controls.update();
renderer.render(scene, camera);
}
animate();
// 响应窗口变化
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
小结
动画和交互让 3D 场景生动起来:
- 渲染循环是动画的基础,使用
requestAnimationFrame和 Clock - 关键帧动画适合预定义的动画序列
- Raycaster 用于检测鼠标与 3D 物体的交互
- 控制器简化了常见的交互模式,如轨道控制、变换控制
下一章我们将学习 Three.js 的高级特性,包括后期处理、着色器等内容。