演示使用 three.js 和 TweenMax(GSAP)构建一个有趣的“排斥力”交互特效。
效果如下:
核心概念
我们要实现的是一个元素网格,当鼠标靠近时,网格内的元素将根据与鼠标的距离改变其 Y 轴位置、旋转角度和缩放大小。
鼠标越靠近某个元素,该元素看起来就会越大。
我们还会定义一个“影响半径”,只有在该半径范围内的元素才会产生响应。半径越大,响应的元素数量越多。
开始准备
首先是 HTML 页面搭建,这是一个基本骨架,因为所有代码都会运行在 canvas 元素内:
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<meta name="target" content="all" />
<meta http-equiv="cleartype" content="on" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<title>Repulsive Force Interaction</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/96/three.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/1.20.3/TweenMax.min.js"></script>
</head>
<body></body>
</html>
这里我们使用了 Three.js v96 (opens in a new tab) 和 GSAP TweenMax (opens in a new tab)。
辅助函数定义
我们先写一些辅助函数,便于后续处理角度、距离、数值映射:
const radians = (degrees) => (degrees * Math.PI) / 180;
const distance = (x1, y1, x2, y2) =>
Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2));
const map = (value, start1, stop1, start2, stop2) =>
((value - start1) / (stop1 - start1)) * (stop2 - start2) + start2;
网格元素
构建网格前,我们先定义三种图形对象:
Box(立方体)
import RoundedBoxGeometry from "roundedBox";
class Box {
constructor() {
this.geom = new RoundedBoxGeometry(0.5, 0.5, 0.5, 0.02, 0.2);
this.rotationX = 0;
this.rotationY = 0;
this.rotationZ = 0;
}
}
Cone(圆锥体)
class Cone {
constructor() {
this.geom = new THREE.ConeBufferGeometry(0.3, 0.5, 32);
this.rotationX = 0;
this.rotationY = 0;
this.rotationZ = radians(-180);
}
}
Torus(圆环)
class Torus {
constructor() {
this.geom = new THREE.TorusBufferGeometry(0.3, 0.12, 30, 200);
this.rotationX = radians(90);
this.rotationY = 0;
this.rotationZ = 0;
}
}
初始化 3D 场景
我们在主类中定义一个 setup
函数:
setup() {
// 用于将鼠标坐标从二维映射到三维空间
this.raycaster = new THREE.Raycaster();
this.gutter = { size: 1 }; // 网格间隔
this.meshes = []; // 所有网格内的图形对象
this.grid = { cols: 14, rows: 6 }; // 网格的列数和行数
this.width = window.innerWidth;
this.height = window.innerHeight;
this.mouse3D = new THREE.Vector2(); // 用于记录鼠标在三维空间中的位置
this.geometries = [new Box(), new Tourus(), new Cone()];
window.addEventListener('mousemove', this.onMouseMove.bind(this), { passive: true });
// 初始化鼠标位置为 (0, 0)
this.onMouseMove({ clientX: 0, clientY: 0 });
}
鼠标移动事件处理
onMouseMove({ clientX, clientY }) {
this.mouse3D.x = (clientX / this.width) * 2 - 1;
this.mouse3D.y = -(clientY / this.height) * 2 + 1;
}
这段代码将鼠标坐标归一化到 [-1, 1] 区间,用于后续 Raycaster 投射。
创建 3D 场景
createScene() {
this.scene = new THREE.Scene();
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(this.renderer.domElement);
}
创建相机
createCamera() {
this.camera = new THREE.PerspectiveCamera(20, window.innerWidth / window.innerHeight, 1);
this.camera.position.set(0, 65, 0); // 顶部俯视角度
this.camera.rotation.x = -1.57; // -90 度,面向下方
this.scene.add(this.camera);
}
获取随机图形
getRandomGeometry() {
return this.geometries[Math.floor(Math.random() * this.geometries.length)];
}
创建 Mesh 辅助函数
getMesh(geometry, material) {
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = true;
mesh.receiveShadow = true;
return mesh;
}
创建网格
createGrid() {
this.groupMesh = new THREE.Object3D(); // 所有图形的容器
const meshParams = {
color: '#ff00ff',
metalness: 0.58,
emissive: '#000000',
roughness: 0.18
};
const material = new THREE.MeshPhysicalMaterial(meshParams);
for (let row = 0; row < this.grid.rows; row++) {
this.meshes[row] = [];
for (let col = 0; col < this.grid.cols; col++) {
const geometry = this.getRandomGeometry();
const mesh = this.getMesh(geometry.geom, material);
mesh.position.set(col + col * this.gutter.size, 0, row + row * this.gutter.size);
mesh.rotation.x = geometry.rotationX;
mesh.rotation.y = geometry.rotationY;
mesh.rotation.z = geometry.rotationZ;
// 初始旋转保存,用于还原
mesh.initialRotation = {
x: mesh.rotation.x,
y: mesh.rotation.y,
z: mesh.rotation.z,
};
this.groupMesh.add(mesh);
this.meshes[row][col] = mesh;
}
}
// 网格整体居中
const centerX = ((this.grid.cols - 1) + ((this.grid.cols - 1) * this.gutter.size)) * 0.5;
const centerZ = ((this.grid.rows - 1) + ((this.grid.rows - 1) * this.gutter.size)) * 0.5;
this.groupMesh.position.set(-centerX, 0, -centerZ);
this.scene.add(this.groupMesh);
}
添加灯光
环境光
addAmbientLight() {
const light = new THREE.AmbientLight('#2900af', 1);
this.scene.add(light);
}
聚光灯
addSpotLight() {
const light = new THREE.SpotLight('#e000ff', 1, 1000);
light.position.set(0, 27, 0);
light.castShadow = true;
this.scene.add(light);
}
面光源(RectAreaLight)
addRectLight() {
const light = new THREE.RectAreaLight('#0077ff', 1, 2000, 2000);
light.position.set(5, 50, 50);
light.lookAt(0, 0, 0);
this.scene.add(light);
}
点光源(可复用)
addPointLight(color, position) {
const light = new THREE.PointLight(color, 1, 1000, 1);
light.position.set(position.x, position.y, position.z);
this.scene.add(light);
}
地板(用于鼠标交互)
addFloor() {
const geometry = new THREE.PlaneGeometry(100, 100);
const material = new THREE.ShadowMaterial({ opacity: 0.3 });
this.floor = new THREE.Mesh(geometry, material);
this.floor.position.y = 0;
this.floor.receiveShadow = true;
this.floor.rotateX(-Math.PI / 2);
this.scene.add(this.floor);
}
动画与交互核心逻辑
所有动画效果都在 draw()
函数中处理,这个函数会在每一帧中被 requestAnimationFrame
调用。
draw() {
// 将鼠标坐标映射到摄像机的视角中
this.raycaster.setFromCamera(this.mouse3D, this.camera);
// 计算鼠标是否与地面(floor)相交
const intersects = this.raycaster.intersectObjects([this.floor]);
if (intersects.length) {
const { x, z } = intersects[0].point;
for (let row = 0; row < this.grid.rows; row++) {
for (let col = 0; col < this.grid.cols; col++) {
const mesh = this.meshes[row][col];
// 鼠标与当前网格元素之间的距离
const mouseDistance = distance(
x,
z,
mesh.position.x + this.groupMesh.position.x,
mesh.position.z + this.groupMesh.position.z
);
// 将距离映射为 Y 轴的抬升高度
const maxPositionY = 10;
const minPositionY = 0;
const startDistance = 6;
const endDistance = 0;
const y = map(mouseDistance, startDistance, endDistance, minPositionY, maxPositionY);
// Y 值不能小于 1,否则动画回落太低
TweenMax.to(mesh.position, 0.4, {
y: y < 1 ? 1 : y,
});
// 根据高度决定缩放因子
const scaleFactor = mesh.position.y / 2.5;
const scale = scaleFactor < 1 ? 1 : scaleFactor;
TweenMax.to(mesh.scale, 0.4, {
ease: Back.easeOut.config(1.7),
x: scale,
y: scale,
z: scale,
});
// 旋转角度随高度变化,产生动态转动效果
TweenMax.to(mesh.rotation, 0.7, {
ease: Back.easeOut.config(1.7),
x: map(mesh.position.y, -1, 1, radians(45), mesh.initialRotation.x),
z: map(mesh.position.y, -1, 1, radians(-90), mesh.initialRotation.z),
y: map(mesh.position.y, -1, 1, radians(90), mesh.initialRotation.y),
});
}
}
}
}
通过上述逻辑,鼠标在地面上的位置会影响到网格中每个元素的垂直位置、缩放比例和旋转角度,形成动态排斥的视觉效果。
效果展示
你可以尝试不同风格的网格排布与图形组合,比如:
多样形状组合:
相机旋转视角:
小结
我们在这个项目中实现了以下内容:
- 创建一个自定义图形网格(立方体、锥体、圆环);
- 利用
Raycaster
侦测鼠标与地面的交互点; - 通过距离映射控制元素的垂直浮动、旋转和缩放;
- 使用 TweenMax(GSAP)让动画过渡更加自然;
- 添加了环境光、聚光灯、区域光、点光源,营造丰富的光影效果。