threejs 都有些啥

three-mind

注意:以下内容适合入门党,并且仅提及一些常见的属性和方法,更全面的内容需要参照官方文档学习

Threejs 的背后是 webGL,WebGL 基于 OpenGL ES 2.0 提供 3d 图形接口,是在浏览器环境下进行 3d/2d 图像渲染的技术。基于它我们可以用 js 做以下这些事:

  • 创建 3d 几何图形
  • 给对象应用材质和纹理
  • 在 3d 场景中操作对象和实现动画
  • 加载 3d 模型

一个典型的 Three.js 应用至少包括渲染器、场景、相机、以及在场景中的物体。下面尝试实现中间放置一辆小车的场景,可以通过鼠标操作查看周围,然后可以通过键盘操作来开车Github 地址

初始化项目

我比较熟悉 React,所以基于 React + Vite + ts 先搭建个基本的项目

npm create vite@latest my-three-app -- --template react-ts

项目结构如下:

src
├─ assets
│  └─ react.svg
├─ components
│  └─ Playground   # 3d区域组件
│     ├─ index.module.css
│     └─ index.tsx
├─ renderer
│  └─ index.ts     # threejs实例
├─ App.css
├─ App.tsx
├─ index.css
├─ main.tsx
└─ vite-env.d.ts

先定义一个 threejs 渲染器的类:

// renderer/index.ts
class Renderer {
  constructor() {
    //...
  }
  init(initPayload: IInitPayload) {
    const container = document.getElementById(initPayload.container);
    const style = getComputedStyle(container!);
    // 获取挂载元素的宽高,后面渲染器要用到
    const width = parseFloat(style.width);
    const height = parseFloat(style.height);
    // ...
  }
}

export const gtaRenderer = new Renderer();

然后在 Playground 组件里初始化这个实例

export function Playground() {
  useEffect(() => {
    gtaRenderer.init({
      container: "playground",
    });
  }, []);

  return <div id="playground" className={styles["playground"]}></div>;
}

当然觉得这样初始化麻烦的话,react-three-fiber 或许是你不错的选择

场景(Scene)

场景是光源、相机和所有物体的父容器,也就是我们前端开发艺术创作的空间了

场景的中心是点(0,0,0),也称为坐标系的原点,坐标系里的一个单位是一米

Three.js 默认使用右手坐标系,因为这是 webGL 默认的坐标系

创建场景对象

const scene = new THREE.Scene();
// 常见属性
// scene.background 设置背景
// scene.children 访问所有物体
// scene.fog 雾化效果,越远越模糊
// 常见方法
// scene.getChildByName(name) 通过指定name访问物体
// scene.traverse((obj) => {}) 遍历物体
// 更多属性参考 https://threejs.org/docs/#api/zh/scenes/Scene

Scene 继承了 Object3D 对象,所以有了 traversegetObjectByName 这些方法 包括后面提到的相机、几何体等

辅助网格

为了更好地理解和定位对象的位置,可以引入GridHelper这个插件,效果可以参考后文的图,使用代码如下:

init(initPayload: IInitPayload) {
  // ...
  const scene = new THREE.Scene();
  const gridHelper = new THREE.GridHelper(100, 30, 0x2c2c2c, 0x888888);
  scene.add(gridHelper);
}

相机(Camera)

有多种相机,比如基于透视投影的镜头PerspectiveCamera ,会模拟人的视觉效果(近大远小),从某个投射中心将物体投射到单一投影面上,是最常使用的投影模式。其他的投影方式还有正交投影,不同投影方式的对比可以参考 http://www.yanhuangxueyuan.com/Three.js_course/camera.html 讲的挺不错的

视锥体(Viewing frustum)

视锥体也就是中间那个椎体,也就是被渲染的物体所在的区域。视锥剔除(View frustum culling) 就是指从渲染过程中移除完全位于视截锥之外的对象的处理步骤

// 相机的观察角度
const fov = 35;
// 相机镜头画面的长宽比(也可以说是视椎体的长宽比),默认长宽比为1
const aspect = container.clientWidth / container.clientHeight;
// 视椎体近截面的距离 near clip plane
const near = 0.1;
// 视椎体远截面的距离 far clip plane
const far = 100;
// 透视投影
const camera = new PerspectiveCamera(fov, aspect, near, far);
// 定位相机
camera.position.set(0, 0, 5);

这里要选择透视投影,更接近现实场景,参考代码如下:

// 创建一个具有透视效果的摄像机
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 800);
// 设置相机位置
camera.position.x = 10;
camera.position.y = 10;
camera.position.z = 30;
// == camera.position.set(10, 10, 30);
// 观察场景中心,默认是原点
camera.lookAt(scene.position);

渲染器(Renderer)

渲染器 WebGLRenderer 会将相机视椎体中的三维场景渲染成一个二维图像显示在 canvas 画布上

const renderer = new THREE.WebGLRenderer({
  antialias: true, // 开启抗锯齿
});
renderer.render(scene, camera);
// 设置颜色及其透明度 setClearColor(string, number)
// renderer.setClearColor(0xffcc00);
// 设置设备像素比,防止 HiDPI 显示器模糊(视网膜显示器)
renderer.setPixelRatio(window.devicePixelRatio);
// 调整输出canvas的宽高并考虑设备像素比
renderer.setSize(width, height);
// 将渲染器的输出(此处是 canvas 元素)插入到 body 中
document.body.appendChild(renderer.domElement);

到这里,threejs 三剑客(场景、相机、渲染器)到齐,一个基础的 threejs 舞台就搭建好了

图形对象

基类图形只有点、线、三角形,其余图形都是在此基础上通过顶点着色算法组合而成

添加到场景中的对象会组成一个场景树,如下图:

那我们这里其实要新建一辆小车对象,这里先用一个长方体替代小车

const cube = new THREE.Mesh(
  new THREE.CubeGeometry(1, 2, 3),
  new THREE.MeshBasicMaterial({
    color: 0xff0000,
  })
);
scene.add(cube);

几何体(Geometry)

常见几何体有以下几种:

  • BoxGeometry 盒状几何体
  • CircleGeometry 圆形几何体
  • ConeGeometry 圆锥形几何体
  • CylinderGeometry 圆筒几何体
  • PlaneGeometry 平面几何体
  • SphereGeometry 球形几何体

BufferGeometry 是什么?

以上几何体其实都是继承自 BufferGeometry。相对于早期的 Geometry 类,BufferGeometry 通过将数据存储为类型化数组(存储在一组连续的内存缓冲区中),可以直接传输给 GPU 进行绘制,提高了数据访问和更新的效率,并且可以自定义顶点数据和索引数据,从而支持更复杂的几何体形状和操作

材质(Material)

常见材质有以下几种:

  • MeshBasicMaterial 基础网孔材质,为几何体赋予一种简单的颜色,或者显示几何体的线框
  • MeshPhongMaterial Phong 网孔材质,考虑光照的影响,可以创建光亮的物体
  • LineBasicMaterial 基础线条材质,可以用于 THREE.Line 几何体,从而创建着色的直线
  • LineDashedMaterial 虚线材质,类似于基础材质,但可以创建虚线效果

纹理(Texture)

需要搭配材质使用。通常用于给物体表面添加贴图,可以理解成物体的皮肤。可以是图像文件,比如 JPEG、PNG 或 GIF,也可以是视频或其他来源生成的图像。可以通过创建 THREE.Texture 对象来加载和使用纹理,使用示例如下:

// 创建纹理对象
const texture = new THREE.TextureLoader().load("texture.jpg");
// 也可以指定一个image对象
// const image = document.getElementById('myImage');
// const texture = new THREE.Texture(image);
// 应用到材质上
const material = new THREE.MeshBasicMaterial({ map: texture });
const geometry = new THREE.BoxGeometry(1, 1, 1);
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

图形对象(Object)

图形对象其实就是上述其他几种对象的封装,网格对象 Mesh 是最常见的可见对象,它将材质和几何体拼装成一个可添加到场景中的对象。其他对象还有 Point/Line/Group

这里涉及到物体的基本转换,分为平移translation、旋转rotation和缩放scale,示例代码如下:

// 平移
cube.translateX(100); // 沿着x轴正方向平移100个单位
const axis = new THREE.Vector3(0, 1, 0);
cube.translateOnAxis(axis, 100); // 沿着axis轴表示方向平移100
// 旋转
mesh.rotateX(Math.PI / 2); // 绕x轴旋转π/2
// 缩放
cube.scale.x = 2; // x轴方向放大2倍
cube.scale.set(0.5, 0.5, 0.5); // 缩小为原来的0.5倍

加边框

MeshBasicMaterial.wireframe属性设置为true,网格对象会显示出线框,也就是网格模型的每一个三角形会通过 Line元素绘制出来,效果如下:

但其实多了一些额外的边框,这里只是给这个长方体加边框,可以用EdgesGeometry来实现,它本质上就是按照一定的算法重新组织已有几何体的顶点数据,然后通过线模型LineSegments绘制出来的几何体

// ...
// 增加边框
// 克隆长方体。克隆操作可以避免重复创建相同的对象,减少内存消耗。不过要注意:自定义属性不会克隆
const box = cubeGeometry.clone();
const edges = new THREE.EdgesGeometry(box);
const edgesMaterial = new THREE.LineBasicMaterial({
  color: 0x333333,
});
const line = new THREE.LineSegments(edges, edgesMaterial);
line.position.x = 0;
line.position.y = 2;
line.position.z = 0;
scene.add(line);

效果如下:

   2

画出线框后,借助 Group对象把长方体和线框组合起来,这样后面操作小车会更方便一些

// ...
const egoCar = new THREE.Group();
egoCar.name = "自车";
egoCar.add(car, line);
// egoCar.translateY(100);
scene.add(egoCar);

创建地面

PlaneGeometry 生成一个地平面

// ...
const planeGeometry = new THREE.PlaneGeometry(200, 200);
// 这里注意选择可以产生阴影的材质
const planeMaterial = new THREE.MeshLambertMaterial({ color: 0xc6c6c6 });
const plane = new THREE.Mesh(planeGeometry, planeMaterial);
// 旋转90°贴合水平面
plane.rotation.x = (1 / 2) * Math.PI;
// 地面接受阴影
plane.receiveShadow = true;
scene.add(plane);

光源

光源照射物体,会产生光影效果。物体的材质在渲染的时候,和光源有很重要的关系,比如物体的纹理、色彩、透明度、光滑度、折射率、反光率等

  • AmbientLight 环境光,它的颜色会添加到整个场景和所有对象的当前颜色上
  • DirectionalLight 平行光,比如太阳光
  • PointLight 点光源,空间中的一点,朝所有的方向发射光线
  • SpotLight 聚光源,由点光源发出,这种类型的光也可以产生投影,有聚光的效果
// 创建环境光
const ambientLight = new THREE.AmbientLight(0xffffff);
scene.add(ambientLight);
// 创建平行光
const directionalLight = new THREE.DirectionalLight(0xffffff);
directionalLight.castShadow = true;
// 设置光源位置
directionalLight.position.set(15, 40, 35);
scene.add(directionalLight);

阴影

需要配合特定光源才能实现阴影效果。开启阴影的话一般需要有以下几个步骤:

// 渲染器要开启
renderer.shadowMap.enabled = true;
// 光源配置
directionalLight.castShadow = true;
// 地面接收阴影
plane.receiveShadow = true;
// 立方体接收阴影
cube.castShadow = true;

阴影的性能负担较大,所以 Threejs 是默认关闭的,一般不建议使用,或者用其他方式模拟(比如纹理贴图)

载入模型

既然做的差不多了,那是时候上真家伙了,咱们去找一个免费的小车模型并加载到场景里,替换掉长方体。常见的模型文件有以下几种:

  • gltf 是现阶段主流的模型类型,可以包含模型、动画、几何图形、材质、灯光、相机,甚至整个场景。glb 是它的二进制形式,体积小很多,不过会把纹理贴图也转成二进制格式,所以实际项目中尽量使用这种,不过 gltf 可读性更好
  • obj 是一种简单的文本格式,可以包含顶点位置、纹理坐标、法线等信息。缺点是文件体积相对较大,不支持二进制数据存储,无法存储动画数据,不支持材质和纹理的自定义属性等
  • mtl 是一种与 obj 配套使用的材质文件格式。mtl 文件包含了 obj 模型所需的材质属性信息,如颜色、纹理、法线贴图等。在加载 obj 模型时,可以通过 OBJLoader 来解析 obj 文件,并通过 MTLLoader 来加载对应的 mtl 文件。MTLLoader 会解析 mtl 文件中的信息,并将材质属性应用于对应的物体
  • fbx是一种 3D 通用模型文件。包含动画、材质特性、贴图、骨骼动画、灯光、摄像机等信息

以加载 fbx 文件为例:

import { FBXLoader } from "three/addons/loaders/FBXLoader.js";

const fbxLoader = new FBXLoader();
// ...
// load
fbxLoader.load("yourModel.fbx", (object) => {
  const mesh = object.children[0];
  mesh.traverse(function (child) {
    child.castShadow = true;
    child.receiveShadow = true;
  });
  mesh.position.set(0, 0, 0);
  mesh.rotation.y = Math.PI;
  mesh.rotation.x = Math.PI / 2;
  mesh.scale.set(0.01, 0.01, 0.01);
  scene.add(mesh);
});
// loadAsync
// const loadedData = await fbxLoader.loadAsync("yourModel.fbx");

好了,到这里基本就搭好一个简单的小车场景了。具体代码可以参考 https://github.com/GitHubJackson/three-gta/blob/main/src/renderer/index.ts

交互

查看场景

引入 OrbitControls 插件,可以方便我们通过鼠标旋转相机查看周围的场景和物体

import { OrbitControls } from "three/addons/controls/OrbitControls.js";
//...
const controls = new OrbitControls(camera, renderer.domElement);
function animate() {
  requestAnimationFrame(animate);
  controls.update();
  renderer.render(scene, camera);
}
animate();

键盘控制

通过上下左右键控制行进方向,并通过长按键盘的时间来模拟一个简单的加减速

// 记录开始按下的时间
let startTime = 0;
// 监听组合键
const activeKeys = new Set();
document.addEventListener("keydown", (e) => {
  activeKeys.add(e.key);
  if (startTime === 0) {
    startTime = Date.now();
  }
  let t = (Date.now() - startTime) / 1000;
  if (t > 10) {
    t = 10;
  }
  if (activeKeys.has("ArrowUp")) {
    carObj.position.z -= t * 0.3;
  }
  if (activeKeys.has("ArrowDown")) {
    carObj.position.z += t * 0.3;
  }
  if (activeKeys.has("ArrowLeft")) {
    carObj.position.x -= t * 0.3;
  }
  if (activeKeys.has("ArrowRight")) {
    carObj.position.x += t * 0.3;
  }
});
document.addEventListener("keyup", (e) => {
  activeKeys.delete(e.key);
  startTime = 0;
});

转弯

键盘事件默认只能监听一些特定的组合键,比如 Ctrl、Shift + 其他键,而这里需要同时监听两个方向键,用 Set 记录。如果同时按了冲突键(就是上对下,左对右),就不处理。然后这里左转右转逻辑是修改小车的 rotate 值,然后上下行进需要参照车头方向。自己实现可能比较麻烦,可以借助 cannon.js来实现这一部分的逻辑,自行发挥哈,后续有时间再完善这一块

性能监控

stats.js 为开发者提供了易用的性能监测功能,它目前支持四种模式:

  • 帧率
  • 每帧的渲染时间
  • 内存占用量
  • 用户自定义

使用组件

npm install stats.js
import Stats from "stats.js";
// ...
const stats = new Stats();
// 0: fps, 1: ms, 2: mb, 3+: custom
stats.showPanel(0);
document.body.appendChild(stats.dom);
function animate() {
  stats.begin();
  controls.update();
  renderer.render(scene, camera);
  // ...其他处理
  stats.end();
  requestAnimationFrame(animate);
}
animate();

其他 tips

自适应屏幕

在屏幕大小变化的时候,需要自动更新场景,确保画面不会被截断

window.addEventListener("resize", onResize, false);
function onResize() {
  const container = document.getElementById(initPayload.container);
  const style = container!.getBoundingClientRect();
  if (style.width) {
    const width = style.width;
    const height = style.height;
    // canvas纵横比变化,需要同步更新相机的aspect属性
    camera.aspect = width / height;
    // 渲染器执行render方法的时候会读取相机对象的投影矩阵属性 projectionMatrix,但不会每一帧都重新计算投影矩阵
    // 如果相机的一些属性发生了变化,需要执行 updateProjectionMatrix 方法更新相机的投影矩阵
    camera.updateProjectionMatrix();
    // 更新画布大小
    renderer.setSize(width, height);
  }
}

设置画布全屏

比如这里设定双击屏幕会打开全屏模式,再次双击退出全屏模式

document.addEventListener("dblclick", () => {
  // 判断当前是否处于全屏模式
  if (document.fullscreenElement) {
    // 退出全屏模式
    document.exitFullscreen();
    return;
  }
  // 全屏展示画布
  renderer.domElement.requestFullscreen();
});

最后

当然,还有一些 TODO,比如引入动画和声音,还有实现加减速和物理碰撞效果,模拟更真实的行车场景,最好还有个司机可以上下车,== 想想还蛮有趣的。原文(有所修改)

参考