跳到主要内容

动画与交互

动画让 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 的高级特性,包括后期处理、着色器等内容。