介绍
本案例会介绍如何使用 threejs 来实现模型的分解和还原,并介绍实现原理。最终效果如下:
演示地址(在 github 上有点卡,请见谅)
初始化场景(天空盒)、相机、控制器、灯光、效果合成器、渲染器
天空盒
// 初始化场景
const initScene = (): void => {
scene = new THREE.Scene();
// 天空图图片集合,指定顺序pos-x, neg-x, pos-y, neg-y, pos-z, neg-z
const skyBg = [
getAssetsFile("sky/px.jpg"),
getAssetsFile("sky/nx.jpg"),
getAssetsFile("sky/py.jpg"),
getAssetsFile("sky/ny.jpg"),
getAssetsFile("sky/pz.jpg"),
getAssetsFile("sky/nz.jpg"),
];
const cubeLoader: THREE.CubeTextureLoader = new THREE.CubeTextureLoader();
skyEnvMap = cubeLoader.load(skyBg);
// 设置场景背景
scene.background = skyEnvMap;
};
相机
const initCamera = (width: number, height: number): void => {
camera = new THREE.PerspectiveCamera(75, width / height, 1, 1000);
camera.position.set(0, 0, 20);
scene.add(camera);
};
控制器
const initControls = (): void => {
controls = new OrbitControls(camera, renderer.domElement);
// 使动画循环使用时阻尼或自转 意思是否有惯性
controls.enableDamping = true;
//是否可以缩放
controls.enableZoom = true;
//是否自动旋转
controls.autoRotate = false;
//是否开启右键拖拽
controls.enablePan = true;
};
灯光
// 初始化灯光
const initLight = (): void => {
// 环境光
const ambientLight: THREE.AmbientLight = new THREE.AmbientLight(
new THREE.Color("rgb(255, 255, 255)")
);
scene.add(ambientLight);
};
效果合成器
const initComposer = () => {
outlinePass = new OutlinePass(
new THREE.Vector2(canvas.value.clientWidth, canvas.value.clientHeight),
scene,
camera
);
outlinePass.visibleEdgeColor.set(0xff0000); // 设置轮廓线颜色为红色
outlinePass.edgeStrength = 10; // 设置轮廓线强度
// 添加 OutlinePass 到渲染器的通道中
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(outlinePass);
};
渲染器
const initRenderer = (width: number, height: number): void => {
renderer = new THREE.WebGLRenderer();
renderer.setSize(width, height);
canvas.value.appendChild(renderer.domElement);
renderer.render(scene, camera);
};
模型分解原理以及实现
分解模型的原理,我个人实现方式是遍历模型中 mesh 并且记录一个 原位置 和 变化位置,最后通过 gsap 动画库进行 mesh.position 的改变
const createModal = (path: string): void => {
gltfLoader.load(
path,
(gltf) => {
// 遍历模型中的所有对象
gltf.scene.traverse((obj: THREE.Object3D) => {
const mesh = obj as THREE.Object3D & {
isMesh: boolean;
material: THREE.Material;
fromPosition: THREE.Vector3;
toPosition: THREE.Vector3;
};
// 记录原位置、移动位置
mesh.fromPosition = mesh.position.clone();
mesh.toPosition = mesh.position.clone().multiplyScalar(3);
});
// 储存模型数据
modelData = gltf.scene as THREE.Group & { decomposition: boolean };
// 模型分解标识
modelData.decomposition = false;
// 模型缓存,用于切换模型时不用重新加载模型,算是一种性能优化方式
modelPool.push(modelData);
scene.add(modelData);
},
(xhr) => {
// 获取模型加载进度
const percent = Math.min((xhr.loaded / xhr.total) * 100, 100);;
console.log(`模型加载进度:${percent}%`)
loadingText.value = `模型加载进度:${percent.toFixed(2)}%`
if(percent === 100) {
setTimeout(() => {
loading.value = false
}, 800)
} else {
loading.value = true
}
}
);
};
监听双击事件,通过模型中定义的分解标识判断当前该执行模型分解才做还是还原操作,并且使用 gsap 动画库改变模型中 mesh 的位置
canvas.value.addEventListener("dblclick", () => {
if (!modelData?.decomposition) {
modelData!.decomposition = true;
modelData?.traverse((obj: THREE.Object3D) => {
const mesh = obj as THREE.Object3D & {
isMesh: boolean;
material: THREE.Material;
fromPosition: THREE.Vector3;
toPosition: THREE.Vector3;
};
gsap.to(mesh.position, {
x: mesh.toPosition.x,
y: mesh.toPosition.y,
z: mesh.toPosition.z,
ease: "Power2.inOut",
duration: 5,
});
});
} else {
modelData!.decomposition = false;
modelData?.traverse((obj: THREE.Object3D) => {
const mesh = obj as THREE.Object3D & {
isMesh: boolean;
material: THREE.Material;
fromPosition: THREE.Vector3;
toPosition: THREE.Vector3;
};
gsap.to(mesh.position, {
x: mesh.fromPosition.x,
y: mesh.fromPosition.y,
z: mesh.fromPosition.z,
ease: "Power2.inOut",
duration: 5,
});
});
}
});
模型切换实现
const switchModel = (key: string) => {
// 查找模型路径
const modelPath = modelPathArr[key as keyof typeof modelPathArr];
// 隐藏当前模型
if (modelData) {
modelData.visible = false;
}
// 查找可重用的模型
let newModel = findReusableModel() as
| (THREE.Group & { decomposition: boolean })
| null;
if (newModel) {
// 重用可用的模型
newModel.visible = true;
modelData = newModel;
} else {
// 查找不到就去加载新模型
createModal(modelPath);
}
};
const findReusableModel = () => {
for (let i = 0; i < modelPool.length; i++) {
const model = modelPool[i];
if (!model.visible && model !== modelData) {
return model;
}
}
return null;
};
选中高亮
选中高亮这边使用到了效果合成器来实现物体边界高亮的效果,在上面初始化时我们已经初始化了效果合成器和物体高亮的属性
对鼠标移动进行监听并使用射线拾取来获取选中物体,然后将选中物体添加到效果合成器的 selectedObjects 属性中,这样就能实现物体边界高亮的效果
const selectMesh = (event: MouseEvent) => {
// 创建鼠标向量
const mouse = new THREE.Vector2();
// 计算鼠标点击位置的归一化设备坐标(NDC)
// NDC 坐标系的范围是 [-1, 1],左下角为 (-1, -1),右上角为 (1, 1)
if (!canvas.value) return;
mouse.x = (event.clientX / canvas.value.clientWidth) * 2 - 1;
mouse.y = -(event.clientY / canvas.value.clientHeight) * 2 + 1;
// 更新射线的起点和方向
raycaster.setFromCamera(mouse, camera);
// 执行射线与物体的相交测试
const intersects = raycaster.intersectObjects(scene.children);
// 检查是否有相交的物体
if (intersects.length > 0) {
const selectedObject = intersects[0].object as THREE.Mesh;
outlinePass.selectedObjects = [selectedObject];
} else {
outlinePass.selectedObjects = [];
}
};
window.addEventListener("mousemove", selectMesh, false);
完整代码
<template>
<div class="box">
<button @click="switchModel('chassis')">模型一</button>
<button @click="switchModel('car')">模型二</button>
</div>
<div id="canvas" ref="canvas"></div>
<div class="mask" v-if="loading">{{ loadingText }}</div>
</template>
<script lang="ts" setup>
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass";
import { OutlinePass } from "three/examples/jsm/postprocessing/OutlinePass";
import Stats from "stats.js";
import gsap from "gsap";
import { ref, nextTick } from "vue";
import { getAssetsFile } from "../utils";
const canvas = ref<any>(null);
let scene: THREE.Scene = new THREE.Scene();
let camera: THREE.PerspectiveCamera;
let renderer: THREE.WebGLRenderer;
let controls: any;
let stats: any;
let skyEnvMap: THREE.CubeTexture;
let modelData: (THREE.Group & { decomposition: boolean }) | null = null;
const gltfLoader: GLTFLoader = new GLTFLoader();
const raycaster = new THREE.Raycaster();
let composer: EffectComposer; // 效果合成器
let outlinePass: OutlinePass;
const modelPathArr = {
chassis: getAssetsFile("chassis/CUSTOM GAMING PC.glb"),
car: getAssetsFile("car/car.glb"),
};
const modelPool: THREE.Group[] = [];
const loading = ref(false);
const loadingText = ref();
nextTick(() => {
initScene();
initCamera(canvas.value.clientWidth, canvas.value.clientHeight);
initRenderer(canvas.value.clientWidth, canvas.value.clientHeight);
initControls();
render();
initStats();
initLight();
initComposer();
switchModel("chassis");
canvas.value.addEventListener("dblclick", () => {
if (!modelData?.decomposition) {
modelData!.decomposition = true;
modelData?.traverse((obj: THREE.Object3D) => {
const mesh = obj as THREE.Object3D & {
isMesh: boolean;
material: THREE.Material;
fromPosition: THREE.Vector3;
toPosition: THREE.Vector3;
};
gsap.to(mesh.position, {
x: mesh.toPosition.x,
y: mesh.toPosition.y,
z: mesh.toPosition.z,
ease: "Power2.inOut",
duration: 5,
});
});
} else {
modelData!.decomposition = false;
modelData?.traverse((obj: THREE.Object3D) => {
const mesh = obj as THREE.Object3D & {
isMesh: boolean;
material: THREE.Material;
fromPosition: THREE.Vector3;
toPosition: THREE.Vector3;
};
gsap.to(mesh.position, {
x: mesh.fromPosition.x,
y: mesh.fromPosition.y,
z: mesh.fromPosition.z,
ease: "Power2.inOut",
duration: 5,
});
});
}
});
});
// 初始化场景
const initScene = (): void => {
scene = new THREE.Scene();
// 天空图图片集合,指定顺序pos-x, neg-x, pos-y, neg-y, pos-z, neg-z
const skyBg = [
getAssetsFile("sky/px.jpg"),
getAssetsFile("sky/nx.jpg"),
getAssetsFile("sky/py.jpg"),
getAssetsFile("sky/ny.jpg"),
getAssetsFile("sky/pz.jpg"),
getAssetsFile("sky/nz.jpg"),
];
const cubeLoader: THREE.CubeTextureLoader = new THREE.CubeTextureLoader();
skyEnvMap = cubeLoader.load(skyBg);
// 设置场景背景
scene.background = skyEnvMap;
};
const initCamera = (width: number, height: number): void => {
camera = new THREE.PerspectiveCamera(75, width / height, 1, 1000);
camera.position.set(0, 0, 20);
scene.add(camera);
};
const initRenderer = (width: number, height: number): void => {
renderer = new THREE.WebGLRenderer();
renderer.setSize(width, height);
canvas.value.appendChild(renderer.domElement);
renderer.render(scene, camera);
};
const initStats = (): void => {
stats = new Stats();
canvas.value.appendChild(stats.dom);
};
const initControls = (): void => {
controls = new OrbitControls(camera, renderer.domElement);
// 使动画循环使用时阻尼或自转 意思是否有惯性
controls.enableDamping = true;
//是否可以缩放
controls.enableZoom = true;
//是否自动旋转
controls.autoRotate = false;
//是否开启右键拖拽
controls.enablePan = true;
};
const render = (): void => {
controls.update();
renderer.render(scene, camera);
if (stats) {
stats.update();
}
if (composer) {
composer.render();
}
requestAnimationFrame(render);
};
// 初始化灯光
const initLight = (): void => {
// 环境光
const ambientLight: THREE.AmbientLight = new THREE.AmbientLight(
new THREE.Color("rgb(255, 255, 255)")
);
scene.add(ambientLight);
};
const switchModel = (key: string) => {
// 查找模型路径
const modelPath = modelPathArr[key as keyof typeof modelPathArr];
// 隐藏当前模型
if (modelData) {
modelData.visible = false;
}
// 查找可重用的模型
let newModel = findReusableModel() as
| (THREE.Group & { decomposition: boolean })
| null;
if (newModel) {
// 重用可用的模型
newModel.visible = true;
modelData = newModel;
} else {
// 查找不到就去加载新模型
createModal(modelPath);
}
};
const findReusableModel = () => {
for (let i = 0; i < modelPool.length; i++) {
const model = modelPool[i];
if (!model.visible && model !== modelData) {
return model;
}
}
return null;
};
const createModal = (path: string): void => {
gltfLoader.load(
path,
(gltf) => {
// 遍历模型中的所有对象
gltf.scene.traverse((obj: THREE.Object3D) => {
const mesh = obj as THREE.Object3D & {
isMesh: boolean;
material: THREE.Material;
fromPosition: THREE.Vector3;
toPosition: THREE.Vector3;
};
// 记录原位置、移动位置
mesh.fromPosition = mesh.position.clone();
mesh.toPosition = mesh.position.clone().multiplyScalar(3);
});
// 储存模型数据
modelData = gltf.scene as THREE.Group & { decomposition: boolean };
// 模型分解标识
modelData.decomposition = false;
// 模型缓存,用于切换模型时不用重新加载模型,算是一种性能优化方式
modelPool.push(modelData);
scene.add(modelData);
},
(xhr) => {
// 获取模型加载进度
const percent = Math.min((xhr.loaded / xhr.total) * 100, 100);
console.log(`模型加载进度:${percent}%`);
loadingText.value = `模型加载进度:${percent.toFixed(2)}%`;
if (percent === 100) {
setTimeout(() => {
loading.value = false;
}, 800);
} else {
loading.value = true;
}
}
);
};
const selectMesh = (event: MouseEvent) => {
// 创建鼠标向量
const mouse = new THREE.Vector2();
// 计算鼠标点击位置的归一化设备坐标(NDC)
// NDC 坐标系的范围是 [-1, 1],左下角为 (-1, -1),右上角为 (1, 1)
if (!canvas.value) return;
mouse.x = (event.clientX / canvas.value.clientWidth) * 2 - 1;
mouse.y = -(event.clientY / canvas.value.clientHeight) * 2 + 1;
// 更新射线的起点和方向
raycaster.setFromCamera(mouse, camera);
// 执行射线与物体的相交测试
const intersects = raycaster.intersectObjects(scene.children);
// 检查是否有相交的物体
if (intersects.length > 0) {
const selectedObject = intersects[0].object as THREE.Mesh;
outlinePass.selectedObjects = [selectedObject];
} else {
outlinePass.selectedObjects = [];
}
};
const initComposer = () => {
outlinePass = new OutlinePass(
new THREE.Vector2(canvas.value.clientWidth, canvas.value.clientHeight),
scene,
camera
);
outlinePass.visibleEdgeColor.set(0xff0000); // 设置轮廓线颜色为红色
outlinePass.edgeStrength = 10; // 设置轮廓线强度
// 添加 OutlinePass 到渲染器的通道中
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(outlinePass);
};
window.addEventListener("mousemove", selectMesh, false);
window.addEventListener("resize", () => {
// 更新摄像机
camera.aspect = canvas.value.clientWidth / canvas.value.clientHeight;
// 更新摄像机投影矩阵
camera.updateProjectionMatrix();
// 更新渲染器
renderer.setSize(canvas.value.clientWidth, canvas.value.clientHeight);
// 设置渲染器的像素比
renderer.setPixelRatio(window.devicePixelRatio);
});
</script>
<style lang="less" scoped>
.box {
position: fixed;
top: 0;
left: 50%;
transform: translateX(-50%);
}
.mask {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
text-align: center;
line-height: 100vh;
}
</style>
原文链接:https://juejin.cn/post/7356445125451005987 作者:半个糯米鸡