three.js实现汽车路径引导
1.概述
前面水了好几篇文章,过意不去,刚好想到之前做的汽车路径引导功能还没进行总结,草草地总结一下,以供参考。
本文将使用three.js实现前端3D汽车路径导航。其实这是从我的一个vue项目里简化出来的功能小案例,但本文不涉及到vue框架,不需要有vue的基础也能看。核心重点在于路线生成和汽车移动效果。我是使用A*来进行路线生成,(但这里我要说的路线检测不会讲解到算法是如何生成路径点的,主要说three.js如何通过算法提供的路径点生成路径,下文用到的点是我已经用算法算出来的);对于汽车的移动效果,涉及到四元数,没有基础的自行了解一下。对于three.js的基础本文只会在涉及到的时候简单讲一下,有兴趣的可以去官网看一下。
2.环境的配置
首先肯定是要配置three.js模块。用到的路径相关的模块需要three.path这个模块,下载方面我是使用的是npm。这里我就不讲了,网上的安装教程已经很全面了。
3.效果
在开始讲解之前,先看一下成品的效果图。
这些模型以及用到的贴图,我都是上网自己找的或者是使用blender搭建出来的,可以根据自己的需要替换成自己的素材。
4.three.js场景搭建
搭建一个简易3D场景,步骤是十分明确的,如下:
- 创建scene
- 创建相机
- 创建光源
- 创建渲染器
- 创建渲染函数
以下是一个本文创建的一个基本场景
import * as THREE from 'three';
import Stats from 'three/addons/libs/stats.module.js'
// 创建场景
const scene = new THREE.Scene();
// 创建性能监视器(看需求添加)
let stats = new Stats();
// 将监视器添加到页面中
document.body.appendChild(stats.domElement)
// 创建相机
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100000);
// 设置相机的位置
camera.position.set(52, 12, 4);
// 添加环境光
const ambient = new THREE.AmbientLight();
ambient.intensity = -0.1;
scene.add(ambient);
// 添加平行光
const dirLight1 = new THREE.DirectionalLight()
dirLight1.position.set(0, 10, 10)
dirLight1.intensity = 0.1;
scene.add(dirLight1)
const dirLight2 = new THREE.DirectionalLight();
dirLight2.position.set(0, 10, -10);
dirLight2.intensity = 0.1;
scene.add(dirLight2);
// 创建渲染器
const renderer = new THREE.WebGLRenderer({
antialias: true
});
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setClearColor('rgba(132, 145, 153, 0)', 1.0);
document.body.appendChild(renderer.domElement)
// 创建轨道控制器(根据需求)
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
function animate() {
// 更新帧数
stats.update()
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
animate();
这样,一个简单的场景就搭建好了,场景中还没有添加物体,所以只能看到背景色。 对于一些设置,这些都是很简单的设置(基本就是看懂英文单词的意思就懂这行代码的大概意思),上three.js官网看一下就能懂的东西,这里也不多说。
5.模型的导入
模型的导入我们分为三步走:导入环境贴图、导入停车场模型以及导入汽车模型。
5.1 导入环境贴图
这里我们首先导入环境贴图,如下:
import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader.js";
const rgbeLoader = new RGBELoader();
rgbeLoader.load(
"/texture/sky.hdr",
(envmap) => {
envmap.mapping = THREE.EquirectangularReflectionMapping;
scene.background = envmap;
scene.environment = envmap;
},
undefined, // 进度回调
(error) => {
console.error("Failed to load the texture:", error); // 错误处理
}
);
这里简单地说一下,这里的 scene.background和 scene.environment所代表的含义是不一样的,前者仅仅是简单地设置为背景,后者是设置为环境贴图,环境贴图是会影响场景的渲染效果的,如物体的反射效果,场景的亮度。
这里我用的是天空的全景图,渲染出来的效果图如下:
5.2 导入停车场模型
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
const gltfLoader = new GLTFLoader();
let textureLoader = new THREE.TextureLoader();
// 解码器的实例化
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('./draco/');
gltfLoader.setDRACOLoader(dracoLoader);
gltfLoader.load(
"./model/park8.glb",
(gltf) => {
gltf.scene.position.set(0, -0.001, 0);
console.log(gltf);
model1 = gltf.scene
scene.add(gltf.scene)
gltf.scene.traverse((child) => {
if (child.isMesh) {
if (child.name.slice(0, 2) === "平面") {
child.position.set(0, -0.1, 0)
const newMaterial = child.material.clone();
newMaterial.color.set(0x84676765);
child.material = newMaterial;
}
if (child.name.slice(0, 2) === '文本') {
const newMaterial = child.material.clone()
newMaterial.color.set(0x0000ff);
arr3.push(child.position)
child.material = newMaterial
}
if (child.name.slice(0, 2) === "立方") {
const newMaterial = child.material.clone();
newMaterial.color.set(0x00ff00);
const texture = textureLoader.load('/texture/grass.jpg')
newMaterial.map = texture
child.material = newMaterial;
}
}
});
}
)
这段代码是,对停车场的中的各个部分的材质进行修改以及导入。这里的解码器我是从three.js模块中将three/examples/jsm/libs/下的draco这个文件夹取了出来,放到了public文件夹中,所以我的 dracoLoader.setDecoderPath('./draco/')的读取路径才是这样,和正常的路径读取会不太一样。
而下面对停车场模型中的各个部分进行处理,是通过每一个部分的命名进行的,由于这个停车场是我自己搭建的,所以我清楚地知道他们的名字是什么,如果你是从网上找的,就要通过控制台将这个模型打印出来查看相应的命名对应的部分,或者是使用相关的建模软件来查看。 下面是我打印出来的命名:
导入停车场后的效果图如下:
5.3 汽车模型的导入
gltfLoader.load(
// 模型路径
"./model/audi.glb",
// 加载完成回调
(gltf) => {
gltf.scene.position.set(35, 0, 0);
gltf.scene.scale.set(1, 1, 1);// 控制模型在场景中的大小
console.log(gltf);
gltf.scene.traverse((child) => {
if (child.isMesh) {
const newMaterial = child.material.clone();
const texture = textureLoader.load('/texture/shen.webp')
newMaterial.map = texture
newMaterial.intensity = 0.1
child.material = newMaterial;
}
}),
model = gltf.scene
model.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), new THREE.Vector3(-1, 0, 0))
scene.add(gltf.scene);
}
);
这个汽车模型我是从网上找到的,由于我实在是不想逐个查看他的各个组成部分的名字,我统一给所有部分添加同一张贴图,效果还可以。
model.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), new THREE.Vector3(-1, 0, 0)) 这里的意思是在调整汽车在场景中的朝向,应用的就是四元数。第一个参数是汽车原来的朝向,第二个参数是我们希望的朝向。参数都是空间向量。
导入所有模型后的效果图如下:
6.创建路线
创建路线分为两步,分别是创建一条贴合路线的曲线和依照这条曲线创建带有贴图的路径。
6.1 曲线的创建
这里创建路线首先需要明确的就是路径点,这里我已经通过算法生成得到相应的路径点(即从起点到某一个停车位)
这里我采用的是三维样条曲线(CatmullRomCurve3),采用它的理由就是因为它更容易适应各种不规则的形状。
代码如下:
function modelCurve() {
const startPoint = new THREE.Vector3().copy(model.position)
// 设置随机终点
const endPoint = new THREE.Vector3(
37,
0.1,
37
)
// 创建曲线
curve = new THREE.CatmullRomCurve3([
startPoint,
new THREE.Vector3(30, 0.1, 0),
new THREE.Vector3(20, 0.1, 0),
new THREE.Vector3(10, 0.1, 0),
new THREE.Vector3(0, 0.1, 0),
new THREE.Vector3(0, 0.1, 10),
new THREE.Vector3(0, 0.1, 20),
new THREE.Vector3(0, 0.1, 25),
new THREE.Vector3(10, 0.1, 25),
new THREE.Vector3(20, 0.1, 25),
new THREE.Vector3(30, 0.1, 25),
new THREE.Vector3(37, 0.1, 25),
endPoint,
])
curve.curveType = "catmullrom"
curve.closed = false// 设置是否闭环
curve.tension = 0.2// 设置张力
}
这里首先是要通过 const startPoint = new THREE.Vector3().copy(model.position)将曲线的起点设置为汽车模型的坐标。
通过向曲线中插入各个关键的控制点来生成曲线,对于剩下的点,会通过插值补全。
这里值得注意的是张力的设置,由于我这个停车场模型的原因,不规则的路线相对会比较少,基本是直线行驶和直角转弯,所以我就将张力调整得比较小,减少曲线的不平滑程度。
6.2 生成路径
通过上面的曲线,我们可以利用three.path这个包提供的方法来生成路径。
如下:
import Bluearrow from "../public/texture/bluearow.webp"
import { PathGeometry, PathPointList } from "three.path";
async function createTexture() {
if (pathToShow) {
scene.remove(pathToShow);
}
try {
arrow = await new THREE.TextureLoader().loadAsync(Bluearrow)
arrow.wrapS = THREE.RepeatWrapping;// 贴图在水平方向上允许重复
arrow.anisotropy = renderer.capabilities.getMaxAnisotropy();
const material = new THREE.MeshPhongMaterial({
map: arrow,//设置贴图
reflectivity: 2,
side: THREE.DoubleSide,// 双面可见
depthTest: true,// 启用深度测试
opacity: 1,
transparent: false,// 允许透明
depthWrite: false,// 设置深度写
blending: THREE.AdditiveBlending
})
const up = new THREE.Vector3(0, 1, 0); // 路径面的法向量
let pathPoints = new PathPointList()
// 用于设置路径点集合的属性
// curve.getPoints(integer) integer表示要将曲线划分为的分段数
pathPoints.set(curve.getPoints(1000), 0.5, 2, up, false)
const geometry = new PathGeometry();
geometry.update(pathPoints, {
width: 4,
arrow: true // 在路径重点是否
})
pathToShow = new THREE.Mesh(geometry, material)
scene.add(pathToShow)
} catch (err) {
console.error(err);
}
}
这里我建议使用async/await 语法,为什么呢?因为,贴图的加载是一个异步的过程,我们需要等待贴图加载完毕再执行下一步的代码,确保纹理在被用作材质时已经正确加载完毕。
arrow.anisotropy = renderer.capabilities.getMaxAnisotropy()这一行代码是用来设置 arrow 贴图 的 各向异性过滤(anisotropic filtering),以提升纹理在斜视角下的渲染质量。什么是各向异性过滤,这里不作解释,自行了解。
pathPoints.set(curve.getPoints(1000), 0.5, 2, up, false)这里使用的是three.path中的PathPointList()里面的set方法来设置需要从曲线上截取的点的数量,以及点和点之间的间隔,重复次数,以及法向量。
PathGeometry 是 three.js 中的一个类,用于表示路径相关的几何形状,第一个参数是路径点数组,第二个参数是一个对象,用来配置路径的宽度和一些别的配置。
7.汽车移动
let progress = 0
const velocity = 0.003
function modelMove() {
if (curve == null || model == null || cubeBody1 == null) {
return
} else {
// 确保 progress 在曲线的有效范围内进行更新。progress 在每次调用 movecar 时都会增加,如果不检查范围,可能会导致超出曲线的边界值。
if (progress <= 1 - velocity) {
// point表示模型当前所在的位置
const point = curve.getPointAt(progress);
// pointBox表示模型下一次所在的位置,因为velocity决定了模型每次更新时的进步量
const pointBox = curve.getPointAt(progress + velocity);
// 这行代码用来检查point和pointBox是否成功初始化
if (point && pointBox) {
model.position.set(point.x, point.y, point.z);
const direction = new THREE.Vector3().subVectors(pointBox, point).normalize();
const up = new THREE.Vector3(0, 1, 0);
// 四元数的一个方法
const targetQuaternion = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), direction); // 这里面的三维向量是需要被归一化
model.quaternion.copy(targetQuaternion); // 将汽车的四元数设置为旋转所需要的四元数
model.quaternion.slerp(targetQuaternion, 0.2); // 用于处理四元数在旋转过程中的球面上插值,差值因子设置为0.2
}
progress += velocity;
} else {
progress = 1
ismoving = false
camera.lookAt(model.position)
return
}
}
}
.getPointAt ( u : Float, optionalTarget : Vector )
- u - 根据弧长在曲线上的位置。必须在范围[0,1]内。
- optionalTarget — (可选) 如果需要, (可选) 如果需要, 结果将复制到此向量中,否则将创建一个新向量。
根据弧长返回曲线上给定位置的点。
const direction = new THREE.Vector3().subVectors(pointBox, point).normalize(); 使用normalize()对direction进行归一化
.setFromUnitVectors ( vFrom : Vector3, vTo : Vector3 )
- 将该四元数设置为从方向向量 vFrom 旋转到方向向量 vTo 所需的旋转。
- 这里我们默认汽车的行进方向是沿z轴的正向,通过计算从汽车默认的行进向量到汽车当前的行进方向的向量所需要的四元数,来调整汽车的朝向。
在做完这个汽车移动的函数之后,我要对他进行控制,所以我使用gui来进行控制。
let eventObj = {
Random: () => {
ismoving = true
progress = 0;
modelCurve()
createTexture()
}
}
const gui = new GUI();
gui.add(eventObj, "Random").name('开始行驶')
你以为这样就大功告成了吗?错,如果不设置相机跟随汽车进行相对移动的话,很有可能在某个角度相机就观察不到汽车的移动了,所以我们还要设置相机的跟随函数。如下:
function start() {
const cameraOffset = new THREE.Vector3(-10, 10, 10);
if (progress == 1 && !ismoving) {
camera.lookAt(model.position)
controls.enabled = true;
return
}
if (model && curve) {
if (arrow) {
arrow.offset.x -= 0.02
}
modelMove()
const modelDirection = curve.getTangent(progress).normalize();
camera.position.copy(model.position).add(cameraOffset);
camera.position.lerp(cameraOffset, 0.2); // 平滑插值
camera.quaternion.copy(model.quaternion)
camera.lookAt(model.position)
}
const cameraOffset = new THREE.Vector3(-10, 10, 10);设置相机的偏移向量
const modelDirection = curve.getTangent(progress).normalize();获取模型在路径上的进程并对其归一化。
最后,将这个start函数放进渲染函数中不断调用就能实现最开始的效果图了。
function animate() {
// 更新帧数
stats.update()
controls.update()
start()
if (progress == 1 && !ismoving) {
controls.target.set(0, 0, 0);
controls.update()
}
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
8.总结
终于写完了,其实也没什么可以总结的,该说的都在上面。three.js我也是刚刚入门,还在努力学习阶段。也没有报什么班,自己从0开始摸索。可以看到的是,小有成果。希望我的这篇文章能够帮助到一些正在学习three.js的人。