threejs 都有些啥

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

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

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

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

初始化项目

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

sql 复制代码
npm create vite@latest my-three-app -- --template react-ts

项目结构如下:

ruby 复制代码
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 渲染器的类:

ts 复制代码
// 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 组件里初始化这个实例

tsx 复制代码
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 默认的坐标系

创建场景对象

ts 复制代码
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这个插件,效果可以参考后文的图,使用代码如下:

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

相机(Camera)

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

视锥体(Viewing frustum)

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

ts 复制代码
// 相机的观察角度
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);

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

ts 复制代码
 // 创建一个具有透视效果的摄像机
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 画布上

ts 复制代码
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 舞台就搭建好了

图形对象

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

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

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

ts 复制代码
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 对象来加载和使用纹理,使用示例如下:

ts 复制代码
 // 创建纹理对象
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,示例代码如下:

scss 复制代码
 // 平移
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绘制出来的几何体

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

ts 复制代码
// ...
// 增加边框
// 克隆长方体。克隆操作可以避免重复创建相同的对象,减少内存消耗。不过要注意:自定义属性不会克隆
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);

效果如下:

创建地面

PlaneGeometry 生成一个地平面

ts 复制代码
// ...
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 聚光源,由点光源发出,这种类型的光也可以产生投影,有聚光的效果
ts 复制代码
// 创建环境光
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);

阴影

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

ts 复制代码
// 渲染器要开启
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 文件为例:

ts 复制代码
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");

好了,到这里基本就搭好一个简单的小车场景了。具体代码可以参考 github.com/GitHubJacks...

交互

查看场景

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

ts 复制代码
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();

键盘控制

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

ts 复制代码
 // 记录开始按下的时间
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 为开发者提供了易用的性能监测功能,它目前支持四种模式:

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

使用组件

bash 复制代码
npm install stats.js
ts 复制代码
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

自适应屏幕

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

ts 复制代码
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);
  }
}

设置画布全屏

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

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

最后

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

相关推荐
岳哥i16 分钟前
前端项目接入单元测试手册
前端·单元测试
小彭努力中21 分钟前
138. CSS3DRenderer渲染HTML标签
前端·深度学习·3d·webgl·three.js
栈老师不回家40 分钟前
Element UI 组件库详解【Vue】
前端·vue.js·ui
前端青山1 小时前
webpack进阶(一)
前端·javascript·webpack·前端框架·node.js
前端与小赵1 小时前
什么是Sass,有什么特点
前端·rust·sass
栈老师不回家1 小时前
axios 请求跨域问题
前端·vue.js
前端拾光者1 小时前
前端数据可视化思路及实现案例
前端·数据库·信息可视化
沉默璇年1 小时前
react中Fragment的使用场景
前端·react.js·前端框架
前端熊猫2 小时前
transform学习
前端·学习·html
GISer_Jing2 小时前
React渲染流程与更新diff算法
前端·javascript·react.js