Three.js案例
TweenMax构建一个有趣的“排斥力”

演示使用 three.js 和 TweenMax(GSAP)构建一个有趣的“排斥力”交互特效。 效果如下: basic-grid

核心概念

我们要实现的是一个元素网格,当鼠标靠近时,网格内的元素将根据与鼠标的距离改变其 Y 轴位置、旋转角度和缩放大小。

top-view

鼠标越靠近某个元素,该元素看起来就会越大。

side-view

我们还会定义一个“影响半径”,只有在该半径范围内的元素才会产生响应。半径越大,响应的元素数量越多。

area-radius

开始准备

首先是 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;
}

创建网格

grid

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);
}

添加灯光

环境光

ambient-light

addAmbientLight() {
  const light = new THREE.AmbientLight('#2900af', 1);
  this.scene.add(light);
}

聚光灯

spot-light

addSpotLight() {
  const light = new THREE.SpotLight('#e000ff', 1, 1000);
  light.position.set(0, 27, 0);
  light.castShadow = true;
  this.scene.add(light);
}

面光源(RectAreaLight)

rectarea-light

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);
}

点光源(可复用)

point-lights

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),
        });
      }
    }
  }
}

通过上述逻辑,鼠标在地面上的位置会影响到网格中每个元素的垂直位置、缩放比例和旋转角度,形成动态排斥的视觉效果。

效果展示

你可以尝试不同风格的网格排布与图形组合,比如:

多样形状组合:

fancy-grid

相机旋转视角:

rotate-camera

小结

我们在这个项目中实现了以下内容:

  • 创建一个自定义图形网格(立方体、锥体、圆环);
  • 利用 Raycaster 侦测鼠标与地面的交互点;
  • 通过距离映射控制元素的垂直浮动、旋转和缩放;
  • 使用 TweenMax(GSAP)让动画过渡更加自然;
  • 添加了环境光、聚光灯、区域光、点光源,营造丰富的光影效果。

原文

https://tympanus.net/codrops/2018/12/06/interactive-repulsion-effect-with-three-js/ (opens in a new tab)