545 lines
19 KiB
Vue
545 lines
19 KiB
Vue
<template>
|
||
<div class="three-viewer">
|
||
<div ref="container" class="canvas-container"></div>
|
||
<div class="annotations-container" ref="annotationsContainer"></div>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import * as THREE from 'three';
|
||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; // 导入 GLTFLoader 用于加载 GLTF 模型
|
||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; // 导入 OrbitControls 用于控制相机
|
||
// 高亮
|
||
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
|
||
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
|
||
import { OutlinePass } from "three/examples/jsm/postprocessing/OutlinePass.js";
|
||
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
|
||
import TextureName from '../../public/models/textureName.json'
|
||
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'
|
||
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer'
|
||
|
||
export default {
|
||
name: 'ThreeModel',
|
||
props: {
|
||
modelPath: {
|
||
type: [String, Number],
|
||
default: '',
|
||
},
|
||
annotations: {
|
||
type: Array,
|
||
default: () => []
|
||
}
|
||
},
|
||
computed: {
|
||
textureObj() {
|
||
return this.$store && this.$store.state.textureObj || {}
|
||
},
|
||
},
|
||
watch: {
|
||
modelPath: {
|
||
handler(v) {
|
||
if(v) {
|
||
if (this.scene) {
|
||
this.scene.clear();
|
||
this.scene = null;
|
||
}
|
||
// 2. 销毁渲染器
|
||
if (this.renderer) {
|
||
this.renderer.dispose();
|
||
this.renderer.forceContextLoss();
|
||
this.renderer = null;
|
||
}
|
||
// 3. 销毁相机
|
||
this.camera = null;
|
||
// 4. 清空画布DOM容器的内容
|
||
if(this.sceneDomElement){
|
||
this.sceneDomElement.innerHTML = '';
|
||
}
|
||
setTimeout(() => {
|
||
this.init()
|
||
}, 300)
|
||
}
|
||
},
|
||
immediate: true
|
||
},
|
||
},
|
||
data() {
|
||
return {
|
||
scene: null, // Three.js场景
|
||
camera: null, // 相机
|
||
renderer: null, // 渲染器
|
||
controls: null, // 轨道控制器
|
||
model: null, // 加载的3D模型
|
||
frameId: null, // 动画帧ID
|
||
sceneDomElement: null, // 容器DOM元素
|
||
parent: null, // 容器
|
||
maxDim: null,
|
||
highlightColor: null, //高亮颜色
|
||
composer: null,
|
||
pmremGenerator: null,
|
||
labelRenderer: null,
|
||
annotationObjects: [],
|
||
selectedPart: null
|
||
};
|
||
},
|
||
mounted () {
|
||
// this.onWindowResize(); // 初始化后立即调用一次
|
||
// 添加窗口大小变化监听
|
||
// window.addEventListener('resize', this.onWindowResize);
|
||
this.resizeObserver = new ResizeObserver(this.onWindowResize);
|
||
this.resizeObserver.observe(this.$refs.container);
|
||
|
||
window.addEventListener('click', this.onMouseClick, false);
|
||
this.highlightColor = new THREE.Color(0xff0000); // 高亮颜色为红色
|
||
},
|
||
beforeDestroy() {
|
||
// 移除事件监听
|
||
// window.removeEventListener('resize', this.onWindowResize);
|
||
window.removeEventListener('resize', this.onWindowResize);
|
||
|
||
// 断开ResizeObserver
|
||
if (this.resizeObserver) {
|
||
this.resizeObserver.disconnect();
|
||
}
|
||
if (this.labelRenderer) {
|
||
this.labelRenderer.domElement.remove()
|
||
}
|
||
},
|
||
destroyed() {
|
||
cancelAnimationFrame(this.frameId);
|
||
if (this.sceneDomElement && this.renderer) {
|
||
this.sceneDomElement.removeChild(this.renderer.domElement);
|
||
}
|
||
if (this.renderer) {
|
||
this.renderer.dispose();
|
||
}
|
||
if (this.scene) {
|
||
this.scene.clear();
|
||
}
|
||
},
|
||
methods: {
|
||
init() {
|
||
if (this.sceneDomElement && this.renderer) {
|
||
this.sceneDomElement.removeChild(this.renderer.domElement);
|
||
}
|
||
// 获取容器元素
|
||
this.sceneDomElement = this.$refs.container;
|
||
// 1. 创建一个新的场景
|
||
this.scene = new THREE.Scene();
|
||
|
||
// 2. 创建相机
|
||
const width = this.sceneDomElement.clientWidth;
|
||
const height = this.sceneDomElement.clientHeight;
|
||
this.camera = new THREE.PerspectiveCamera(55, width / height, 0.1, 1000);
|
||
|
||
// 3. 创建渲染器
|
||
this.renderer = new THREE.WebGLRenderer({
|
||
antialias: true,
|
||
alpha: true,
|
||
});
|
||
|
||
// CSS2D渲染器(用于标注)
|
||
this.labelRenderer = new CSS2DRenderer()
|
||
this.labelRenderer.setSize(width, height)
|
||
this.labelRenderer.domElement.style.position = 'absolute'
|
||
this.labelRenderer.domElement.style.top = '0'
|
||
this.labelRenderer.domElement.style.pointerEvents = 'none'
|
||
this.$refs.annotationsContainer.appendChild(this.labelRenderer.domElement)
|
||
|
||
// 设置画布分辨率 提高画质
|
||
this.renderer.setPixelRatio(window.devicePixelRatio);
|
||
// 渲染器开启阴影效果
|
||
this.renderer.shadowMap.enabled = true
|
||
|
||
this.renderer.setSize(width, height);
|
||
// this.renderer.setClearColor(0xcccccc);
|
||
this.renderer.outputEncoding = THREE.sRGBEncoding; // 设置颜色编码
|
||
this.sceneDomElement.appendChild(this.renderer.domElement);
|
||
|
||
//4. 添加光源
|
||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); // 创建环境光
|
||
this.scene.add(ambientLight); // 将环境光添加到场景中
|
||
|
||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.6); // 平行光
|
||
directionalLight.position.set(1, 1, 1);
|
||
this.scene.add(directionalLight);
|
||
directionalLight.castShadow = true;
|
||
|
||
// 环境光
|
||
this.pmremGenerator = new THREE.PMREMGenerator(this.renderer)
|
||
this.pmremGenerator.compileEquirectangularShader()
|
||
const env = this.pmremGenerator.fromScene(new RoomEnvironment(this.renderer), 0.04).texture
|
||
this.scene.environment = env
|
||
|
||
// 3. 添加地面(接收阴影)
|
||
const groundGeometry = new THREE.PlaneGeometry(8, 8);
|
||
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0xcccccc, side: THREE.DoubleSide });
|
||
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
|
||
ground.rotation.x = -Math.PI / 2;
|
||
ground.position.y = -1;
|
||
ground.receiveShadow = true; // ⭐ 关键:地面接收阴影
|
||
this.scene.add(ground);
|
||
|
||
// 5. 添加轨道控制器
|
||
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||
this.controls.enableDamping = true; // 启用阻尼效果
|
||
this.controls.dampingFactor = 0.25; // 阻尼系数
|
||
|
||
// 6. 加载3D模型
|
||
this.loadModel();
|
||
|
||
// 7. 开始动画循环
|
||
this.animate();
|
||
|
||
},
|
||
|
||
loadModel() {
|
||
const loader = new GLTFLoader();
|
||
const textureLoader = new THREE.TextureLoader();
|
||
console.log(this.modelPath);
|
||
loader.load(
|
||
this.modelPath,
|
||
// 'https://skeps-bucket.oss-cn-shenzhen.aliyuncs.com/2026%2FFile%2F4d3a9525-c415-4ab2-b916-d87b4b86ebcbSK010020001001BLQ_%E4%BA%A4%E6%B5%81%E9%81%BF%E9%9B%B7%E5%99%A8%2C%E5%B8%A6%E6%94%AF%E6%92%91%E4%BB%B6%E5%9B%BA%E5%AE%9A%E9%97%B4%E9%9A%99.glb',
|
||
(gltf) => {
|
||
// 移除旧模型
|
||
if (this.model) {
|
||
this.scene.remove(this.model);
|
||
}
|
||
this.model = gltf.scene;
|
||
|
||
this.scene.add(this.model);
|
||
|
||
// 遍历模型所有子元素
|
||
this.model.traverse((child) => {
|
||
if (child.isMesh) {
|
||
console.log(7777, child)
|
||
child.material.emissiveMap = child.material.map;
|
||
child.castShadow = true; // 让模型产生阴影
|
||
child.receiveShadow = true; // 让模型接受阴影
|
||
|
||
// child.material.emissive = child.material.color;
|
||
let name = child.material.name.split('.')[0]
|
||
let path = TextureName[name]
|
||
|
||
if(path) {
|
||
if(this.textureObj[name]) {
|
||
child.material = this.textureObj[name]
|
||
child.material.needsUpdate = true;
|
||
} else {
|
||
let texture = textureLoader.load(`/models/texture/${encodeURIComponent(path)}.jpg`)
|
||
texture.wrapS = THREE.RepeatWrapping;
|
||
texture.wrapT = THREE.RepeatWrapping;
|
||
texture.repeat.set( 1, 1 );
|
||
let material = null
|
||
if(name.includes('金属')) {
|
||
material = new THREE.MeshStandardMaterial({
|
||
map: texture,
|
||
// color: 0x049EF4,
|
||
// emissive: 0x000000,
|
||
metalness: 0.55, // 金属感
|
||
roughness: 0.05 // 粗糙度,0为镜面反射
|
||
})
|
||
} else {
|
||
material = new THREE.MeshPhongMaterial( {
|
||
// ...child.material,
|
||
map: texture,
|
||
emissiveMap: child.material.map,
|
||
shininess: 0,
|
||
} );
|
||
}
|
||
child.material = material
|
||
child.material.needsUpdate = true;
|
||
this.textureObj[name] = material
|
||
// this.$store.commit('set_textureObj', this.textureObj)
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// 计算模型包围盒并居中
|
||
const box = new THREE.Box3().setFromObject(this.model);
|
||
const center = box.getCenter(new THREE.Vector3());
|
||
const size = box.getSize(new THREE.Vector3());
|
||
|
||
// 根据模型大小调整相机
|
||
const maxDim = Math.max(size.x, size.y, size.z);
|
||
this.maxDim = maxDim
|
||
this.camera.position.z = this.maxDim * 1;
|
||
this.camera.near = maxDim / 100;
|
||
this.camera.far = maxDim * 100;
|
||
this.camera.updateProjectionMatrix();
|
||
|
||
// 创建一个父对象用于旋转
|
||
this.parent = new THREE.Object3D(); // 创建一个新的 Object3D 作为父对象
|
||
this.parent.add(this.model); // 将模型添加到父对象
|
||
|
||
this.scene.add(this.parent); // 将父对象添加到场景
|
||
|
||
// 让控制器聚焦到模型中心 聚焦中心
|
||
this.controls.target.copy(center);
|
||
|
||
this.controls.enableRotate = true;
|
||
this.controls.minPolarAngle = Math.PI / 2; // 限制向下旋转的角度
|
||
this.controls.maxPolarAngle = Math.PI / 2; // 限制向上旋转的角度
|
||
|
||
this.controls.update();
|
||
this.createAnnotations()
|
||
this.$emit('model-loaded')
|
||
},
|
||
(progress) => {
|
||
const percent = (progress.loaded / progress.total) * 100
|
||
this.$emit('loading-progress', percent)
|
||
},
|
||
(error) => {
|
||
console.error('模型加载失败:', error);
|
||
this.$emit('model-error', error)
|
||
}
|
||
);
|
||
},
|
||
|
||
animate() {
|
||
this.frameId = requestAnimationFrame(this.animate);
|
||
if(this.parent && this.isPlay) {
|
||
this.parent.rotation.y += 0.001; // 沿 Y 轴旋转父对象
|
||
}
|
||
// 更新控制器
|
||
if (this.controls) {
|
||
this.controls.update();
|
||
}
|
||
|
||
// 渲染场景
|
||
if (this.renderer && this.scene && this.camera) {
|
||
this.renderer.render(this.scene, this.camera);
|
||
}
|
||
|
||
if (this.composer) {
|
||
this.composer.render(this.scene, this.camera);
|
||
}
|
||
|
||
if(this.labelRenderer && this.scene && this.camera) {
|
||
this.labelRenderer.render(this.scene, this.camera)
|
||
}
|
||
},
|
||
|
||
// 响应式调整大小
|
||
onWindowResize() {
|
||
if (this.camera && this.renderer) {
|
||
const width = this.sceneDomElement.clientWidth;
|
||
const height = this.sceneDomElement.clientHeight;
|
||
this.camera.aspect = width / height;
|
||
this.camera.updateProjectionMatrix();
|
||
this.renderer.setSize(width, height);
|
||
}
|
||
},
|
||
// 点击射线
|
||
onMouseClick(event) {
|
||
console.log('我点击了')
|
||
// const raycaster = new THREE.Raycaster();
|
||
// const mouse = new THREE.Vector2();
|
||
// // 计算鼠标在屏幕上的位置并转换为Normalized device coordinates (NDC)
|
||
// console.log(this.sceneDomElement.clientWidth, window.innerWidth)
|
||
// mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
|
||
// mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
|
||
|
||
// // 更新Raycaster的射线位置和方向
|
||
// raycaster.setFromCamera(mouse, this.camera);
|
||
if(!this.sceneDomElement) return
|
||
let getBoundingClientRect = this.sceneDomElement.getBoundingClientRect()
|
||
// 屏幕坐标转标准设备坐标
|
||
let x = ((event.clientX - getBoundingClientRect .left) / this.sceneDomElement.offsetWidth) * 2 - 1;// 标准设备横坐标
|
||
let y = -((event.clientY - getBoundingClientRect .top) / this.sceneDomElement.offsetHeight) * 2 + 1;// 标准设备纵坐标
|
||
let standardVector = new THREE.Vector3(x, y, 1);// 标准设备坐标
|
||
// 标准设备坐标转世界坐标
|
||
let worldVector = standardVector.unproject(this.camera);
|
||
// 射线投射方向单位向量(worldVector坐标减相机位置坐标)
|
||
let ray = worldVector.sub(this.camera.position).normalize();
|
||
// 创建射线投射器对象
|
||
let rayCaster = new THREE.Raycaster(this.camera.position, ray);
|
||
|
||
// 计算物体和射线的交点
|
||
const intersects = rayCaster.intersectObjects(this.model.children, true);
|
||
if (intersects.length !== 0 && intersects[0].object instanceof THREE.Mesh) {
|
||
// this.controls.target.copy(intersects[0].point);
|
||
let selectedObject = intersects[0].object;
|
||
let selectedObjects = [];
|
||
selectedObjects.push(selectedObject.parent);
|
||
this.outlineObj(selectedObjects);
|
||
} else {
|
||
console.log('没找到', this.composer)
|
||
this.composer = null
|
||
}
|
||
},
|
||
outlineObj(selectedObjects) {
|
||
let outlinePass;
|
||
let renderPass;
|
||
let unrealBloomPass;
|
||
|
||
// 创建一个EffectComposer(效果组合器)对象,然后在该对象上添加后期处理通道。
|
||
// 用于模型边缘高亮
|
||
this.composer = new EffectComposer(this.renderer);
|
||
this.composer.renderTarget1.texture.outputColorSpace = THREE.sRGBEncoding;
|
||
this.composer.renderTarget2.texture.outputColorSpace = THREE.sRGBEncoding;
|
||
this.composer.renderTarget1.texture.encoding = THREE.sRGBEncoding;
|
||
this.composer.renderTarget2.texture.encoding = THREE.sRGBEncoding;
|
||
|
||
// 新建一个场景通道 为了覆盖到原来的场景上
|
||
renderPass = new RenderPass(this.scene, this.camera);
|
||
this.composer.addPass(renderPass);
|
||
// 物体边缘发光通道
|
||
outlinePass = new OutlinePass(
|
||
new THREE.Vector2(this.sceneDomElement.clientWidth, this.sceneDomElement.clientHeight),
|
||
this.scene,
|
||
this.camera,
|
||
selectedObjects
|
||
);
|
||
outlinePass.selectedObjects = selectedObjects;
|
||
outlinePass.edgeStrength = 10.0; // 边框的亮度
|
||
outlinePass.edgeGlow = 0.5; // 光晕[0,1]
|
||
outlinePass.usePatternTexture = false; // 是否使用父级的材质
|
||
outlinePass.edgeThickness = 1.0; // 边框宽度
|
||
outlinePass.downSampleRatio = 1; // 边框弯曲度
|
||
outlinePass.pulsePeriod = 5; // 呼吸闪烁的速度
|
||
outlinePass.visibleEdgeColor.set(parseInt(0x00ff00)); // 呼吸显示的颜色
|
||
outlinePass.hiddenEdgeColor = new THREE.Color(0, 0, 0); // 呼吸消失的颜色
|
||
outlinePass.clear = true;
|
||
this.composer.addPass(outlinePass);
|
||
// 自定义的着色器通道 作为参数
|
||
// effectFXAA = new ShaderPass(FXAAShader);
|
||
// effectFXAA.uniforms.resolution.value.set(
|
||
// 1 / window.innerWidth,
|
||
// 1 / window.innerHeight
|
||
// );
|
||
// effectFXAA.renderToScreen = true;
|
||
// composer.addPass(effectFXAA);
|
||
// // 抗锯齿
|
||
// smaaPass = new SMAAPass();
|
||
// composer.addPass(smaaPass);
|
||
// // 发光效果
|
||
unrealBloomPass = new UnrealBloomPass();
|
||
unrealBloomPass.strength = 0.1;
|
||
unrealBloomPass.radius = 0;
|
||
unrealBloomPass.threshold = 1;
|
||
this.composer.addPass(unrealBloomPass);
|
||
|
||
// this.scene.background = new THREE.Color(0x1b1824);
|
||
},
|
||
// 创建标签
|
||
createAnnotations() {
|
||
// 清除旧标注
|
||
this.annotationObjects.forEach(obj => {
|
||
this.scene.remove(obj)
|
||
})
|
||
this.annotationObjects = []
|
||
|
||
// 创建标注点的预设位置(圆形分布)
|
||
const radius = 2
|
||
const angleStep = (Math.PI * 2) / this.annotations.length
|
||
|
||
this.annotations.forEach((annotation, index) => {
|
||
const angle = angleStep * index
|
||
const x = Math.cos(angle) * radius
|
||
const z = Math.sin(angle) * radius
|
||
const y = 1 + Math.sin(index) * 0.5
|
||
console.log(666, annotation.name, x, y, z)
|
||
// 创建标注标签
|
||
const labelDiv = document.createElement('div')
|
||
labelDiv.className = 'annotation-label'
|
||
labelDiv.textContent = `${index + 1} ${annotation.name}`
|
||
labelDiv.style.cssText = `
|
||
background: rgba(64, 158, 255, 0.9);
|
||
color: white;
|
||
padding: 5px 10px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
pointer-events: auto;
|
||
`
|
||
labelDiv.addEventListener('click', () => {
|
||
this.onAnnotationClick(annotation, index)
|
||
})
|
||
|
||
const label = new CSS2DObject(labelDiv)
|
||
label.position.set(x/10, y/10, z/10)
|
||
label.userData = { annotation, index }
|
||
|
||
this.scene.add(label)
|
||
this.annotationObjects.push(label)
|
||
|
||
// 创建连接线
|
||
const points = [
|
||
new THREE.Vector3(0, 0, 0),
|
||
new THREE.Vector3(x/10, y/10, z/10)
|
||
// new THREE.Vector3(0.1, 0.1, 0.1)
|
||
]
|
||
const geometry = new THREE.BufferGeometry().setFromPoints(points)
|
||
const material = new THREE.LineBasicMaterial({
|
||
color: 0x409eff,
|
||
transparent: true,
|
||
opacity: 0.5
|
||
})
|
||
const line = new THREE.Line(geometry, material)
|
||
this.scene.add(line)
|
||
this.annotationObjects.push(line)
|
||
})
|
||
},
|
||
onAnnotationClick(annotation, index) {
|
||
this.highlightPart(index)
|
||
this.$emit('annotation-click', annotation, index)
|
||
},
|
||
|
||
highlightPart(index) {
|
||
// 重置之前的高亮
|
||
if (this.selectedPart !== null) {
|
||
this.annotationObjects.forEach((obj, i) => {
|
||
if (obj.isLine) {
|
||
obj.material.opacity = 0.5
|
||
} else if (obj.element) {
|
||
obj.element.style.background = 'rgba(64, 158, 255, 0.9)'
|
||
obj.element.style.transform = 'scale(1)'
|
||
}
|
||
})
|
||
}
|
||
|
||
// 高亮选中的部件
|
||
this.selectedPart = index
|
||
this.annotationObjects.forEach((obj, i) => {
|
||
const objIndex = Math.floor(i / 2)
|
||
if (objIndex === index) {
|
||
if (obj.isLine) {
|
||
obj.material.opacity = 1
|
||
obj.material.color.setHex(0xff6b00)
|
||
} else if (obj.element) {
|
||
obj.element.style.background = 'rgba(255, 107, 0, 0.95)'
|
||
obj.element.style.transform = 'scale(1.1)'
|
||
}
|
||
}
|
||
})
|
||
},
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.three-viewer {
|
||
width: 100%;
|
||
height: 100%;
|
||
position: relative;
|
||
}
|
||
|
||
.canvas-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.annotations-container {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
pointer-events: none;
|
||
}
|
||
|
||
</style>
|