autopilot 系列更新到第五篇了,这一篇分享些跟自车相关的优化。关于 threejs 的模型加载,可以用 promise 封装一下,然后提供 abort 方法中断模型加载避免重叠问题,其次是模型加载速度的一些优化,比如 draco 压缩、缓存策略等,然后还有提供相机视角的切换,然后可以做个车灯光源提高下视觉效果
模型相关
自车用的是 gltf 模型,所以下面主要围绕这个格式来说
模型加载进度
默认模型加载器都有内置一个 DefaultLoadingManager,可以参考 GLTFLoader
// ...
loadEgoCar() {
gltfLoader.load(
carModel,
(gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
car.rotateX(Math.PI / 2);
car.rotateY(Math.PI);
this.scene.add(car);
},
(xhr) => {
console.log((xhr.loaded / xhr.total) * 100 + "% loaded");
},
function (error) {
console.log(error);
}
);
}
THREE.LoadingManager
可以定制模型加载器
const manager = new THREE.LoadingManager();
manager.onLoad = () => {
console.log("===Loading complete!");
};
manager.onProgress = (url, loaded, total) => {
console.log("===loading", url, loaded, total);
};
const gltfLoader = new GLTFLoader(manager);
对于 GLTFLoader
,它可能会加载多个资源(如网格、纹理、材质等),并且这些资源的加载可能是异步的,LoadingManager
的 onProgress
回调中的 total
值可能只代表当前正在处理或已处理的特定资源的一部分或全部的总数据量,所以这里简单用 loaded/total 是不太准确的
加载函数
因为模型加载默认是回调函数,这里可以做个 promise 的封装,实现异步加载和渐进式显示
// src/helper/index.ts
import { GLTF, GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
export function loadGLTFWithPromise(url: string): Promise<GLTF> {
return new Promise((resolve, reject) => {
const loader = new GLTFLoader();
loader.load(
url,
function (gltf) {
resolve(gltf);
},
// 加载进度回调
undefined,
function (error) {
reject(error);
}
);
});
}
// src/renderer/index.ts
// ...
loadEgoCar() {
loadGLTFWithPromise(carModel)
.then((gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
car.rotateX(Math.PI / 2);
car.rotateY(Math.PI);
this.scene.add(car);
})
.catch((err) => console.log(err));
}
加载贴图
贴图也是异步加载的,这里和模型的加载可能存在顺序问题,可以用 Promise.allSettled
确保都模型和对应贴图加载完再更新。如果此时任一方加载失败了,那也可以在这里做个兜底处理,比如贴图换占位图或者提示加载模型失败。当然,如果贴图确实加载的很慢,那还是分开请求避免阻塞模型加载,更新贴图的逻辑其实类似的
// src/helper/index.ts
export function loadTexture(url: string): Promise<THREE.Texture> {
return new Promise((resolve, reject) => {
const loader = new THREE.TextureLoader();
loader.load(
url,
(texture) => {
resolve(texture);
},
undefined,
(error) => {
reject(error);
}
);
});
}
// ...
// 在没渲染之前,threejs默认会用占位图替代,避免报错
import testModal from "@/assets/models/xxx.glb";
import testTextures from "@/assets/textures/xxx.png";
Promise.allSettled([
loadGLTFWithPromise(testModal),
loadTexture(testTextures),
]).then((results) => {
const modelResult = results[0];
const textureResult = results[1];
if (modelResult.status === "fulfilled") {
if (textureResult.status === "fulfilled") {
modelResult.value.material.map = textureResult.value;
modelResult.value.material.needsUpdate = true;
}
return;
}
// 其他异常情况可以自行补充,比如提示模型加载失败或者贴图加载失败
// 实际可能有多个模型需要贴图
// model.traverse((child) => {
// if (child.isMesh) {
// model.material.map = texture;
// // 在下一帧应用更新
// model.material.needsUpdate = true;
// }
// });
});
中断异步加载
需要提供中断异步加载的功能,避免模型没加载完时用户又换成到其他模型,这种场景在有多个自车模型的时候会碰到,如果不中断可能造成模型重叠。因为之前用 promise 封装了,所以这里其实就等同于实现一个 promise 的 abort
方法
// 中断promise的辅助函数
export function abortWrapper(p1: Promise<any>) {
let abort;
const p2 = new Promise((resolve, reject) => (abort = reject));
const p = Promise.race([p1, p2]);
p.abort = abort;
return p;
}
// 比如可以调整下自车的加载逻辑
// ...
(this.egoCarLoader = abortWrapper(loadGLTFWithPromise(carModel))).then(
(gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
car.rotateX(Math.PI / 2);
car.rotateY(Math.PI);
this.scene.add(car);
}
);
// ...
// 可能某个信号触发中断,直接调用abort, 这里用setTimeout模拟中断效果
setTimeout(() => {
this.egoCarLoader.abort();
}, 10);
但其实还不是很彻底,因为请求还是会正常进行和接收,那怎么中断请求?这一步倒不是很必要,也能做,但是得自行写一个模型加载器,然后通过 xhr 对象的 abort
方法或者 AbortController
来取消请求。可以跟踪下 threejs 的这个 issue,目前还是 open 状态,似乎有希望在未来版本中支持 abort
压缩
自车模型有 9M 左右,还是比较大,可以考虑压缩下模型,GLTF 模型可以通过高效的 Draco 算法来压缩几何数据,并且借助了 webAssembly 来加速计算,但是肯定有额外的解压耗时,一般都是值得的,当然你可以做下耗时对比
npm 全局安装 gltf-pipeline
,然后到对应模型的目录下执行 gltf-pipeline -i xxx.glb -o xxx.glb -d
。或者找个在线压缩 gltf 的地址快速压缩,比如 这个。如果经常要压缩一些模型的话,每次都这么手动处理肯定很麻烦,可以尝试用 node 写个批处理脚本
压缩后 9M 降到了 3.4M,体积减小还是很明显的。得到压缩后的文件后,那就该准备 DRACOLoader
了。新增一个针对压缩后模型的加载函数
export function loadDracoGLTFWithPromise(url: string): Promise<GLTF> {
return new Promise((resolve, reject) => {
const loader = new GLTFLoader();
const dracoLoader = new DRACOLoader();
// 设置解压相关文件的路径
dracoLoader.setDecoderPath(
// 将 three/examples/jsm/libs/draco/gltf/ 拷贝到 public 目录来用
"./draco-gltf/"
// 或者也可以直接用这个
// "https://threejs.org/examples/jsm/libs/draco/gltf/"
);
// 使用js方式解压
// dracoLoader.setDecoderConfig({ type: "js" });
// 初始化 initDecoder 解码器
dracoLoader.preload();
// 设置GLTFLoader使用的压缩器
loader.setDRACOLoader(dracoLoader);
loader.load(
url,
function (gltf) {
resolve(gltf);
},
// 加载进度回调
undefined,
function (error) {
reject(error);
}
);
});
}
缓存
一般场景里用到的模型,比如行人、障碍物、路标等都是经常会出现的,这个时候不能做重复加载,而是要尽可能复用。这里其实做个 map 对象,在接口数据里约定 type 来映射模型就行,可以在初始化界面的时候发起并行请求(Promise.all
)获取模型,并保存到 map 对象上,后面可以直接通过 clone
复用模型
因为有些识别出来的物体是会连续存在于多帧里的,可以用id
唯一标识,这类非新增的物体就可以通过 id 找到模型并更新位置、大小、颜色等属性
其他关于模型的优化还有:
- 纹理压缩
- 建模的时候尽量减少顶点和面的数量,借助建模软件做网格简化和合并,从而实现减面
- LOD 技术(Level of Detail),其实就是根据物体与相机的距离,动态选择渲染不同精度的模型。其实可以配合上述的纹理压缩和减面来做
- CDN/…
有机会实践后再分享下吧 ~
相机优化
Threejs 相机有透视相机和正交相机。透视相机能模拟我们人眼所看到的景象,它是 web3d 中使用得最普遍的
视角切换
因为 autopilot 这个应用主要是用于智驾算法调试,所以肯定涉及到视角切换,比如跟车视角、俯视横向纵向、又或者用户希望能自定义视角,那就需要设计这个视角切换的功能
先设计这几种视角:跟车、俯视横向、俯视纵向,其实实现很简单,就是改变相机的位置和方向。这里需要增加一个悬浮层,放一些交互按钮,注意悬浮层要加一个pointer-events: none
来阻止鼠标行为,包括点击、悬停等交互,避免和 OrbitControls
交互冲突
// ...
switchCameraView(view: EViewType) {
this.cameraView = view;
switch (view) {
case EViewType.FollowCar: {
this.camera.up.set(0, 0, 1);
this.camera.position.set(-0.4, 4, 1.4);
break;
}
case EViewType.Overlook: {
this.camera.position.set(0, 0, 20);
this.camera.up.set(0, -1, 0);
break;
}
case EViewType.OverlookVertical: {
this.camera.position.set(0, 0, 20);
this.camera.up.set(1, 0, 0);
break;
}
default:
break;
}
}
上面这个还只是简单的调了下位置,这里 OrbitControls
在俯视视角用起来还有点问题,后面得优化下
车灯
我们之前其实接触过光源,比如整体场景的环境光 AmbientLight
还有投向自车的直行光 DirectionLight
,其他光源还有 SpotLight
聚光灯
车灯主要就是借助聚光灯光源来模拟,如果自车模型包含灯模型,通过灯的位置来设置光源位置就更准确了。但如果自车位置会变的话,还需要结合自车位置和转向来计算光源目标
// ...
const target1 = new THREE.Object3D();
target1.position.set(0.1, -0.2, 0.3);
const light1 = new THREE.SpotLight("#fff", 1.2, 3, Math.PI / 6, 0.1);
light1.position.set(0.1, 0.2, 0.3);
light1.castShadow = true;
light1.target = target1;
this.scene.add(target1);
this.scene.add(light1);
const target2 = new THREE.Object3D();
target2.position.set(-0.1, -0.2, 0.3);
const light2 = new THREE.SpotLight("#fff", 1.2, 2, Math.PI / 6, 0.1);
light2.position.set(-0.1, 0.2, 0.3);
light2.castShadow = true;
light2.target = target2;
this.scene.add(target2);
this.scene.add(light2);