注意:以下内容适合入门党,并且仅提及一些常见的属性和方法,更全面的内容需要参照官方文档学习
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 对象,所以有了 traverse、getObjectByName 这些方法 , 包括后面提到的相机、几何体等
辅助网格
为了更好地理解和定位对象的位置,可以引入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);
效果如下:
画出线框后,借助 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,比如引入动画和声音,还有实现加减速和物理碰撞效果,模拟更真实的行车场景,最好还有个司机可以上下车,== 想想还蛮有趣的。原文(有所修改)