2、Three.js入门:安装与引入、创建第一个3D场景(场景、相机、渲染器)、理解动画循环

好,咱们正式开始动手了。

上一章聊了3D的基础概念,这一章直接上代码。Three.js是目前最流行的Web 3D库,没有之一。我个人习惯用它来快速搭建原型,因为API设计得很友好,社区也活跃。你想想看,如果让你从零写一个WebGL渲染器,光矩阵运算就能劝退90%的人。Three.js把这些脏活累活都封装好了,我们只需要关注「场景长什么样」就行。

2.1 安装与引入:别纠结,选一种方式就行

Three.js的引入方式有好几种,我挑最常用的三种说。

方式一:CDN直接引入(最快上手)

适合写个Demo玩玩,或者做个小工具页面。直接在HTML里加个script标签:

<script type="importmap">
{
  "imports": {
    "three": "https://unpkg.com/three@0.160.0/build/three.module.js"
  }
}
</script>

<script type="module">
import * as THREE from 'three';
// 你的代码从这里开始
</script>

这里用了importmap,说白了就是给浏览器指个路——告诉它「three」这个包名对应哪个URL。我在早期项目里经常这么干,因为不用装npm,打开浏览器就能跑。

方式二:npm安装(推荐正式项目)

如果你在用Vue、React这类框架,或者项目规模比较大,建议走npm:

npm install three@0.160.0

然后在代码里引入:

import * as THREE from 'three';

嗯,这里要注意版本号。我踩过坑——不同版本的Three.js有些API不兼容,比如Geometry在r125之后改成了BufferGeometry。所以建议锁定版本,别用^符号。

方式三:CDN传统script标签(不推荐)

有些老教程会让你这么写:

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>

这种方式会把所有东西挂到window.THREE上。我个人不太喜欢,因为全局污染、模块化也不好做。除非你是在写纯静态页面,否则别用。

我的建议: 初学阶段用CDN + importmap,省事。等你要做正式项目了,再切到npm。

2.2 创建第一个3D场景:场景、相机、渲染器

Three.js里有个铁三角:场景(Scene)相机(Camera)渲染器(Renderer)。缺一个都玩不转。

打个比方:场景就是舞台,相机就是你的眼睛,渲染器就是把舞台上的画面拍下来显示到屏幕上的人。

2.2.1 场景(Scene)

场景就是个容器,所有物体、灯光、辅助线都放在里面。创建它很简单:

const scene = new THREE.Scene();

默认场景是黑色的。你可以给它加个背景色:

scene.background = new THREE.Color(0x87CEEB); // 天蓝色

我在项目中遇到过一个问题——场景背景色和CSS背景色搞混了。记住,Three.js的场景背景色只影响3D画布内部,不影响页面其他部分。

2.2.2 相机(Camera)

相机决定了你从哪个角度、以什么视角看场景。最常用的是透视相机(PerspectiveCamera)

const camera = new THREE.PerspectiveCamera(
  75,          // 视野角度(FOV),类似人眼的视角范围
  window.innerWidth / window.innerHeight, // 宽高比
  0.1,         // 近裁剪面
  1000         // 远裁剪面
);
camera.position.set(0, 2, 5); // 把相机往后挪5个单位,往上抬2个单位
camera.lookAt(0, 0, 0);       // 让相机看向原点

这里有个坑——近裁剪面和远裁剪面。说白了就是相机能看到的最远和最近距离。如果你把物体放在相机后面,或者离得太远,它就消失了。我曾经调试一个模型半天不显示,最后发现是远裁剪面设得太小,模型被裁掉了。

注意: 近裁剪面不要设成0,否则会出现深度冲突。一般设0.1或1就够了。

2.2.3 渲染器(Renderer)

渲染器负责把场景和相机的内容画到屏幕上。WebGL渲染器是默认选择:

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio); // 适配高清屏
document.body.appendChild(renderer.domElement);

antialias: true是抗锯齿,能让边缘更平滑。我建议一直开着,性能影响不大。

setPixelRatio这行很多人会忘。如果不加,在Retina屏(比如MacBook)上画面会模糊。你想想看,一个高清屏上跑模糊的3D,多掉价。

2.2.4 加个物体试试

光有舞台和相机还不够,得放个东西看看效果。加个立方体:

const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

// 加个灯光,不然MeshStandardMaterial是黑的
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 5, 5);
scene.add(light);

然后渲染一帧:

renderer.render(scene, camera);

打开浏览器,你应该能看到一个绿色的立方体。如果看不到,检查三点:相机位置、物体位置、灯光。我刚开始学的时候经常忘了加灯光,结果场景一片黑,还以为代码写错了。

2.3 理解动画循环:让场景动起来

上面那个例子只渲染了一帧。要让立方体旋转起来,需要持续不断地渲染。这就引出了动画循环

2.3.1 requestAnimationFrame

浏览器提供了一个API叫requestAnimationFrame,它会在每次屏幕刷新时调用你传入的函数。一般屏幕是60Hz,也就是每秒调用60次。

function animate() {
  requestAnimationFrame(animate); // 递归调用,形成循环

  // 更新物体的状态
  cube.rotation.x += 0.01;
  cube.rotation.y += 0.01;

  // 渲染
  renderer.render(scene, camera);
}

animate(); // 启动循环

这段代码做了三件事:

  • 请求下一帧
  • 更新物体状态(旋转角度)
  • 重新渲染场景

为什么不用setInterval?因为requestAnimationFrame更智能——页面切到后台时会自动暂停,不浪费性能。而且它能和浏览器的刷新率同步,画面更流畅。

核心要点: 动画循环 = 更新状态 + 重新渲染。缺一不可。

2.3.2 时间增量(Delta Time)

上面那个旋转速度是固定的——每帧转0.01弧度。但有个问题:不同显示器的刷新率不一样。60Hz的屏幕每秒转0.6弧度,120Hz的屏幕每秒转1.2弧度。结果就是,高刷屏上物体转得飞快。

解决办法是用时间增量

let clock = new THREE.Clock();

function animate() {
  requestAnimationFrame(animate);

  const delta = clock.getDelta(); // 获取上一帧到这一帧的时间差(秒)
  cube.rotation.x += 0.5 * delta; // 每秒转0.5弧度
  cube.rotation.y += 0.5 * delta;

  renderer.render(scene, camera);
}

这样不管屏幕是60Hz还是144Hz,物体每秒转动的角度都一样。我在做游戏项目时,所有运动逻辑都依赖delta,否则不同设备上体验差异巨大。

2.3.3 窗口自适应

用户调整浏览器窗口大小时,画布尺寸也得跟着变。不然画面会拉伸或变形:

window.addEventListener('resize', () => {
  const width = window.innerWidth;
  const height = window.innerHeight;

  camera.aspect = width / height;   // 更新宽高比
  camera.updateProjectionMatrix();  // 通知相机重新计算投影矩阵

  renderer.setSize(width, height);  // 更新渲染尺寸
});

这行camera.updateProjectionMatrix()很容易忘。如果不调,画面比例会错乱。我有个同事因为这个bug调了一下午,最后发现就是少调了这行。

2.4 完整示例代码

把上面所有内容拼起来,就是一个完整的入门Demo:

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Three.js 入门</title>
  <style>
    body { margin: 0; overflow: hidden; }
  </style>
</head>
<body>
  <script type="importmap">
  {
    "imports": {
      "three": "https://unpkg.com/three@0.160.0/build/three.module.js"
    }
  }
  </script>

  <script type="module">
    import * as THREE from 'three';

    // 1. 场景
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x87CEEB);

    // 2. 相机
    const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
    camera.position.set(0, 2, 5);
    camera.lookAt(0, 0, 0);

    // 3. 渲染器
    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(window.innerWidth, window.innerHeight);
    renderer.setPixelRatio(window.devicePixelRatio);
    document.body.appendChild(renderer.domElement);

    // 4. 物体
    const geometry = new THREE.BoxGeometry(1, 1, 1);
    const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
    const cube = new THREE.Mesh(geometry, material);
    scene.add(cube);

    // 5. 灯光
    const light = new THREE.DirectionalLight(0xffffff, 1);
    light.position.set(5, 5, 5);
    scene.add(light);

    // 6. 动画循环
    const clock = new THREE.Clock();

    function animate() {
      requestAnimationFrame(animate);

      const delta = clock.getDelta();
      cube.rotation.x += 0.5 * delta;
      cube.rotation.y += 0.5 * delta;

      renderer.render(scene, camera);
    }

    animate();

    // 7. 窗口自适应
    window.addEventListener('resize', () => {
      const width = window.innerWidth;
      const height = window.innerHeight;

      camera.aspect = width / height;
      camera.updateProjectionMatrix();
      renderer.setSize(width, height);
    });
  </script>
</body>
</html>

2.5 本章小结

这一章我们干了三件事:

  1. 安装引入——CDN还是npm,看场景选
  2. 搭建铁三角——场景装东西,相机定视角,渲染器画出来
  3. 动画循环——用requestAnimationFrame驱动,用delta保证一致性

说实话,Three.js入门就这么点东西。你把这个Demo跑通了,后面学光照、材质、模型加载都会轻松很多。下一章我们聊聊「响应式布局」——怎么让3D场景在不同屏幕上都好看。

小练习: 把立方体换成球体(SphereGeometry)或环面(TorusGeometry),看看效果。再试试改改颜色和旋转速度。