Three.js案例
时空隧道效果

3D 时空隧道效果

基于 Three.jsGSAP 的动态“时空隧道”效果。

整个“时空隧道”的核心是通过动画、纹理和相机运动的配合,让用户产生一种不断向前、且管道随视觉变化而扭曲延伸的沉浸感。

一、项目准备

在开始前,你需要确保已经在网页环境中引用了以下库:

<script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/",
      "gsap": "https://esm.sh/[email protected]"
    }
  }
</script>

示例中可以使用 <script type="importmap"> 来设置 importmap,也可以直接通过传统的 <script> 标签在页面中引入这些脚本资源。关键是要保证环境能正确导入 THREEgsap

同时,你需要准备好一张纹理图片(示例中命名为 img.jpg),也要确保这张图片能被正确加载到场景中。

二、主要实现步骤

1. 初始化场景、相机、渲染器

  1. Scene(场景):三维世界的容器,所有物体都将加入到其中。
  2. Camera(相机):决定了你从哪个角度和位置去观察场景。这里使用 PerspectiveCamera(透视相机)。
  3. Renderer(渲染器):用来将三维场景渲染到屏幕(Canvas)上,这里用 WebGLRenderer
// 创建场景
const scene = new THREE.Scene();
 
// 创建渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(ww, wh);
document.body.appendChild(renderer.domElement);
 
// 创建相机
const camera = new THREE.PerspectiveCamera(15, ww / wh, 0.01, 1000);
camera.rotation.y = Math.PI;
camera.position.z = 0.35;

小提示camera.rotation.y = Math.PI; 让相机向反方向看,结合后面管道的朝向,可以让我们更直观地在“管道”内前进。

2. 创建 CatmullRomCurve3 曲线

为了生成一个可以动态改变形状的管道,我们使用 Three.js 提供的 CatmullRomCurve3 曲线。该曲线可以基于多个点自动生成平滑的曲线。

const points = [];
for (let i = 0; i < 5; i++) {
  points.push(new THREE.Vector3(0, 0, 3 * (i / 4)));
}
points[4].y = -0.06;
 
const curve = new THREE.CatmullRomCurve3(points);
curve.type = "catmullrom";

我们这里给定了 5 个点,每个点在 z 轴上做了步进,这样就形成了一条大体朝前方(z 轴正方向)的曲线。

3. 将曲线生成管道

TubeGeometry 可以把一条三维曲线“挤”成一根可指定半径的管道。我们只需要把曲线 curve 和一些额外参数(分段数、管道半径、管道横截面边数等)传进去即可。

const tubeGeometry = new THREE.TubeGeometry(curve, 70, 0.02, 30, false);
const tubeMaterial = new THREE.MeshBasicMaterial({
  side: THREE.BackSide,
  map: texture, // 后续会在这里加载纹理
});
const tubeMesh = new THREE.Mesh(tubeGeometry, tubeMaterial);
scene.add(tubeMesh);

注意这里指定了 side: THREE.BackSide,让我们可以“站在管道里面”观察到管道的纹理。

4. 加载并设置纹理

我们通过 TextureLoader 异步加载本地图片,然后指定给 tubeMaterial.map 来给管道贴图。并且通过 wrapSwrapT 设置成 THREE.MirroredRepeatWrapping,可让纹理在重复时镜像翻转,避免在重复拼接时出现明显的缝隙;repeat.set(textureParams.repeatX, textureParams.repeatY) 则决定了在 S 和 T 方向上的重复次数。

const loader = new THREE.TextureLoader();
loader.load("./img.jpg", function (texture) {
  const tubeMaterial = new THREE.MeshBasicMaterial({
    side: THREE.BackSide,
    map: texture,
  });
  tubeMaterial.map.wrapS = THREE.MirroredRepeatWrapping;
  tubeMaterial.map.wrapT = THREE.MirroredRepeatWrapping;
  tubeMaterial.map.repeat.set(10, 4); // textureParams.repeatX, textureParams.repeatY
  ...
});

5. 通过 GSAP 给纹理和相机增加动画

  1. 纹理动画:使用了一个 timeline 来反复改变 textureParams.offsetXtextureParams.repeatX,并在渲染循环里根据这些参数更新纹理贴图的偏移量和重复值。这样就可以让纹理“滚动”或“变速”。
  2. 相机抖动:同样通过 GSAP 的 timeline,不断改变一个名为 cameraShake.x 的数值,并将这个数值加到相机的 position.x 上,以模拟相机随机抖动的效果。
// 纹理动画
const hyperSpace = gsap.timeline({ repeat: -1 });
hyperSpace.to(textureParams, {
  duration: 4,
  repeatX: 0.3,
  ease: "power1.inOut",
});
hyperSpace.to(
  textureParams,
  {
    duration: 12,
    offsetX: 8,
    ease: "power2.inOut",
  },
  0
);
hyperSpace.to(
  textureParams,
  {
    duration: 6,
    repeatX: 10,
    ease: "power2.inOut",
  },
  "-=5"
);
 
// 相机抖动动画
const shake = gsap.timeline({ repeat: -1, repeatDelay: 5 });
shake.to(
  cameraShake,
  {
    duration: 2,
    x: -0.01,
    ease: "rough({ ... })",
  },
  4
);
shake.to(cameraShake, {
  duration: 2,
  x: 0,
  ease: "rough({ ... })",
});

6. 鼠标移动与相机位置联动

在文章开头我们定义了一个 mouse 对象,用来追踪鼠标位置(target)和实际需要缓动到的位置(position)。在每一帧中,我们使用缓动公式将鼠标的当前位置和相机位置对应起来,得到一个 ratio(相对屏幕宽高的比例),并把这个比例转化成相机偏移位置。

document.body.addEventListener("mousemove", function (e) {
  mouse.target.x = e.clientX;
  mouse.target.y = e.clientY;
});
 
// 在渲染循环中
mouse.position.x += (mouse.target.x - mouse.position.x) / 50;
mouse.position.y += (mouse.target.y - mouse.position.y) / 50;
 
mouse.ratio.x = mouse.position.x / ww;
mouse.ratio.y = mouse.position.y / wh;
 
camera.position.x = mouse.ratio.x * 0.044 - 0.025 + cameraShake.x;
camera.position.y = mouse.ratio.y * 0.044 - 0.025;

这样,我们就能通过移动鼠标来“左右上下”微调相机位置,配合管道本身的动画,就会给人一种沉浸式在隧道中穿梭的感觉。

7. 动态更新管道几何

代码中还有一个关键点:在每一帧中,会根据 mouse.ratio.xmouse.ratio.y 重新调整 CatmullRomCurve3 的某些控制点,从而使管道本身也在动态弯曲变化。接着,会用新的曲线更新 TubeGeometry。为了节省开销,先尝试直接复制新的几何顶点到现有几何体上,只有在顶点数量不一致(极少见)时才替换整个几何体。

// 在渲染循环里,根据鼠标移动来调整曲线点
if (curve && curve.points) {
  curve.points[2].x = 0.6 * (1 - mouse.ratio.x) - 0.3;
  ...
}
 
// 创建新几何,再将新的 positions 拷贝到旧几何
if (curve) {
  const newTubeGeometry = new THREE.TubeGeometry(curve, 70, 0.02, 30, false);
  ...
  if (oldPositions.count === newPositions.count) {
    oldPositions.copy(newPositions);
    oldPositions.needsUpdate = true;
  } else {
    tubeMesh.geometry = newTubeGeometry;
    currentTubeGeometry = newTubeGeometry;
  }
}

这样做的好处是可以在不销毁或重建 Mesh 的情况下,让管道边绕曲线动态发生形变,更加平滑且节省性能。

四、总结

  1. 三维场景的基础搭建:Scene + Camera + Renderer。
  2. 使用管道几何:Three.js 提供的 TubeGeometry 可以很方便地将任意曲线变成一个可视化的三维管道。
  3. 纹理加载与动画:通过 TextureLoader 获取纹理,并结合 GSAP 的 timeline 可以轻松实现各种循环动画效果。
  4. 鼠标交互与相机运动:将鼠标位置转化为一个归一化的比例,通过缓动公式更新相机位置,从而让用户在移动鼠标时感觉到隧道的动态移动。
  5. 曲线更新:可以在每一帧中根据鼠标等输入实时更新 CatmullRomCurve3 的控制点,然后更新 TubeGeometry,从而实现管道的“弯曲”效果。

代码

https://github.com/calmound/threejs-demo/tree/main/cubeliu (opens in a new tab)

参考

https://github.com/Mamboleoo/InfiniteTubes/blob/master/index3.html (opens in a new tab)