初始化项目

这里使用 Next.js 作为项目基础框架,这也是目前 React 官方推荐的起步方案之一。

1
npx create-next-app@latest

基本上使用默认选项即可。

安装 Three.js

1
2
npm install --save three
npm install @types/three --save-dev

使用 Three.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
"use client";
import { useState, useCallback } from "react";
import * as THREE from "three";

const initScene = (node: HTMLDivElement) => {
const width = node.clientWidth;
const height = node.clientHeight;
const scene = new THREE.Scene();

const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setClearColor(0xffffff);
renderer.setSize(width, height);
node.appendChild(renderer.domElement);
camera.position.z = 5;

const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshNormalMaterial();
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

const animate = () => {
requestAnimationFrame(animate);
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
renderer.render(scene, camera);
};
animate();
};

export default function Home() {
const [initialized, setInitialized] = useState(false);

const threeDivRef = useCallback(
(node: HTMLDivElement | null) => {
if (node !== null && !initialized) {
initScene(node);
setInitialized(true);
}
},
[initialized]
);

return (
<main>
<div
className="flex items-center justify-center h-screen"
ref={threeDivRef}
></div>
</main>
);
}

需要注意,这里要使用 useCallback 而不是 useRef 避免不必要的重新渲染。因为在 Hooks 的组件中,state 改变后会引起父组件的重新渲染,每次渲染都会生成一个新函数。useCallback 通过缓存函数,无论渲染多少次,都是同一个函数,可以避免额外的性能和开销。

由于 Next.js 现在默认引入 Tailwind,所以这里直接其定义的样式,如果不使用 Tailwind,可以使用 style 设置样式:

1
2
3
4
5
6
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh'
}}

监听窗口大小变化

由于这里已经有一个动画 animate,可以在这里监听窗口大小是否发生变化,重新调整渲染器大小来适应变化。当然使用传统的 window.addEventListener 添加 resize 的事件处理也是可以的。

首先确保 canvas 填满 div,可以在 node.appendChild 之后添加样式:

1
2
3
renderer.domElement.style.width = "100%";
renderer.domElement.style.height = "100%";
renderer.domElement.style.display = "block";

判断宽度和高度是否匹配的辅助函数,可以考虑将它放入到某些类似工具类的通用代码里:

1
2
3
4
5
6
7
8
9
10
const resizeRendererToDisplaySize = (renderer: THREE.WebGLRenderer) => {
const canvas = renderer.domElement;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
const needResize = canvas.width !== width || canvas.height !== height;
if (needResize) {
renderer.setSize(width, height, false);
}
return needResize;
};

animate 中判断大小是否发生变化:

1
2
3
4
5
6
7
8
9
10
const animate = () => {
requestAnimationFrame(animate);

if (resizeRendererToDisplaySize(renderer)) {
const canvas = renderer.domElement;
camera.aspect = canvas.clientWidth / canvas.clientHeight;
camera.updateProjectionMatrix();
}

// ...

鼠标控制场景

使用 Three.js 内置的 OrbitControls

1
2
3
4
5
6
7
8
import { OrbitControls } from "three/addons/controls/OrbitControls.js";

const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 2, 0);

// ...

controls.update();

调整数值

推荐使用 Three.js 内置的 lil-gui,而不是 dat.gui。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { GUI } from "three/examples/jsm/libs/lil-gui.module.min.js";

const [controls] = useState({ rotationSpeed: 0.01 });

// ...

useEffect(() => {
const gui = new GUI({ width: 200 });
gui.add(controls, "rotationSpeed", 0, 0.1);

return () => {
if (gui) {
gui.destroy();
}
};
}, []);

性能面板

1
2
3
4
5
6
7
8
9
import Stats from "three/addons/libs/stats.module.js";

export function initStats(type: number, node: HTMLDivElement) {
let stats = new Stats();
stats.showPanel(type);
node.appendChild(stats.dom);
stats.dom.style.position = "absolute";
return stats;
}