创建一个 ModelComponent.vue
文件,用于封装单个3D模型。
<template>
<!-- 3D模型组件不需要模板,直接通过 three.js 渲染 -->
</template>
<script>
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
export default {
name: 'ModelComponent',
props: {
longitude: {
type: Number,
required: true,
},
latitude: {
type: Number,
required: true,
},
modelType: {
type: String,
required: true,
},
},
data() {
return {
model: null,
};
},
mounted() {
this.loadModel();
},
methods: {
loadModel() {
const loader = new GLTFLoader();
// 根据模型类别加载不同的模型文件
let modelPath = '';
switch (this.modelType) {
case 'building':
modelPath = '/models/building.gltf';
break;
case 'tree':
modelPath = '/models/tree.gltf';
break;
default:
console.error('Unknown model type:', this.modelType);
return;
}
loader.load(modelPath, (gltf) => {
this.model = gltf.scene;
// 将模型添加到父组件的场景中
this.$parent.scene.add(this.model);
// 设置模型的位置
this.updatePosition();
});
},
updatePosition() {
if (!this.model || !this.$parent.map) return;
// 将经纬度转换为 three.js 场景中的 x, y 坐标
const lngLat = [this.longitude, this.latitude];
const pixelCoordinates = this.$parent.map.project(lngLat);
const x = pixelCoordinates.x - window.innerWidth / 2;
const y = -pixelCoordinates.y + window.innerHeight / 2;
const z = 0; // 可以根据需要调整高度
this.model.position.set(x, y, z);
},
},
beforeDestroy() {
// 组件销毁时从场景中移除模型
if (this.model && this.$parent.scene) {
this.$parent.scene.remove(this.model);
}
},
};
</script>
在主组件中,遍历后端返回的数据,动态创建多个 ModelComponent
。
<template>
<div ref="mapContainer" class="map-container">
<!-- 动态加载模型组件 -->
<model-component
v-for="(item, index) in modelData"
:key="index"
:longitude="item.longitude"
:latitude="item.latitude"
:model-type="item.type"
/>
</div>
</template>
<script>
import mapboxgl from 'mapbox-gl';
import * as THREE from 'three';
import ModelComponent from './ModelComponent.vue';
export default {
name: 'MapWith3DModels',
components: {
ModelComponent,
},
data() {
return {
map: null,
scene: null,
camera: null,
renderer: null,
modelData: [], // 后端返回的模型数据
};
},
mounted() {
this.initMap();
this.initThree();
this.fetchModelData();
},
methods: {
initMap() {
mapboxgl.accessToken = 'YOUR_MAPBOX_ACCESS_TOKEN';
this.map = new mapboxgl.Map({
container: this.$refs.mapContainer,
style: 'mapbox://styles/mapbox/streets-v11',
center: [初始经度, 初始纬度], // 设置地图中心点
zoom: 15, // 设置地图缩放级别
pitch: 60, // 设置地图倾斜角度
});
this.map.on('load', () => {
this.map.addLayer({
id: 'custom-layer',
type: 'custom',
renderingMode: '3d',
onAdd: (map, gl) => {
this.initThreeRenderer(gl);
},
render: (gl, matrix) => {
this.renderThreeScene(matrix);
},
});
});
},
initThree() {
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
this.camera.position.set(0, 0, 100);
},
initThreeRenderer(gl) {
this.renderer = new THREE.WebGLRenderer({
canvas: this.map.getCanvas(),
context: gl,
});
this.renderer.autoClear = false;
},
renderThreeScene(matrix) {
const m = new THREE.Matrix4().fromArray(matrix);
this.camera.projectionMatrix = m;
this.renderer.resetState();
this.renderer.render(this.scene, this.camera);
this.map.triggerRepaint();
},
fetchModelData() {
// 模拟后端返回的数据
this.modelData = [
{ longitude: 经度1, latitude: 纬度1, type: 'building' },
{ longitude: 经度2, latitude: 纬度2, type: 'tree' },
// 更多数据...
];
},
},
};
</script>
<style scoped>
.map-container {
width: 100%;
height: 100vh;
}
</style>
扩展 :为模型添加点击事件,显示详细信息
1.在 ModelComponent.vue
中,为模型添加点击事件检测逻辑。
<template>
<!-- 3D模型组件不需要模板,直接通过 three.js 渲染 -->
</template>
<script>
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
export default {
name: 'ModelComponent',
props: {
longitude: {
type: Number,
required: true,
},
latitude: {
type: Number,
required: true,
},
modelType: {
type: String,
required: true,
},
modelInfo: {
type: Object,
required: true,
},
},
data() {
return {
model: null,
};
},
mounted() {
this.loadModel();
this.addClickEvent();
},
methods: {
loadModel() {
const loader = new GLTFLoader();
// 根据模型类别加载不同的模型文件
let modelPath = '';
switch (this.modelType) {
case 'building':
modelPath = '/models/building.gltf';
break;
case 'tree':
modelPath = '/models/tree.gltf';
break;
default:
console.error('Unknown model type:', this.modelType);
return;
}
loader.load(modelPath, (gltf) => {
this.model = gltf.scene;
// 将模型添加到父组件的场景中
this.$parent.scene.add(this.model);
// 设置模型的位置
this.updatePosition();
// 为模型添加自定义属性,存储模型信息
this.model.userData = this.modelInfo;
});
},
updatePosition() {
if (!this.model || !this.$parent.map) return;
// 将经纬度转换为 three.js 场景中的 x, y 坐标
const lngLat = [this.longitude, this.latitude];
const pixelCoordinates = this.$parent.map.project(lngLat);
const x = pixelCoordinates.x - window.innerWidth / 2;
const y = -pixelCoordinates.y + window.innerHeight / 2;
const z = 0; // 可以根据需要调整高度
this.model.position.set(x, y, z);
},
addClickEvent() {
// 监听鼠标点击事件
window.addEventListener('click', this.onMouseClick, false);
},
onMouseClick(event) {
if (!this.model || !this.$parent.camera || !this.$parent.scene) return;
// 获取鼠标点击位置的归一化设备坐标 (NDC)
const mouse = new THREE.Vector2();
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
// 创建 Raycaster
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, this.$parent.camera);
// 检测与模型的交集
const intersects = raycaster.intersectObject(this.model, true);
if (intersects.length > 0) {
// 如果点击了模型,触发事件并显示详细信息
this.$emit('model-clicked', this.model.userData);
}
},
},
beforeDestroy() {
// 组件销毁时移除事件监听器
window.removeEventListener('click', this.onMouseClick);
if (this.model && this.$parent.scene) {
this.$parent.scene.remove(this.model);
}
},
};
</script>
2.在主组件中监听 ModelComponent
的 model-clicked
事件,并显示详细信息。
<template>
<div ref="mapContainer" class="map-container">
<!-- 动态加载模型组件 -->
<model-component
v-for="(item, index) in modelData"
:key="index"
:longitude="item.longitude"
:latitude="item.latitude"
:model-type="item.type"
:model-info="item.info"
@model-clicked="showModelInfo"
/>
<!-- 弹窗组件 -->
<div v-if="selectedModel" class="modal">
<div class="modal-content">
<h2>{{ selectedModel.name }}</h2>
<p>{{ selectedModel.description }}</p>
<button @click="selectedModel = null">关闭</button>
</div>
</div>
</div>
</template>
<script>
import mapboxgl from 'mapbox-gl';
import * as THREE from 'three';
import ModelComponent from './ModelComponent.vue';
export default {
name: 'MapWith3DModels',
components: {
ModelComponent,
},
data() {
return {
map: null,
scene: null,
camera: null,
renderer: null,
modelData: [], // 后端返回的模型数据
selectedModel: null, // 当前选中的模型信息
};
},
mounted() {
this.initMap();
this.initThree();
this.fetchModelData();
},
methods: {
initMap() {
mapboxgl.accessToken = 'YOUR_MAPBOX_ACCESS_TOKEN';
this.map = new mapboxgl.Map({
container: this.$refs.mapContainer,
style: 'mapbox://styles/mapbox/streets-v11',
center: [初始经度, 初始纬度], // 设置地图中心点
zoom: 15, // 设置地图缩放级别
pitch: 60, // 设置地图倾斜角度
});
this.map.on('load', () => {
this.map.addLayer({
id: 'custom-layer',
type: 'custom',
renderingMode: '3d',
onAdd: (map, gl) => {
this.initThreeRenderer(gl);
},
render: (gl, matrix) => {
this.renderThreeScene(matrix);
},
});
});
},
initThree() {
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
this.camera.position.set(0, 0, 100);
},
initThreeRenderer(gl) {
this.renderer = new THREE.WebGLRenderer({
canvas: this.map.getCanvas(),
context: gl,
});
this.renderer.autoClear = false;
},
renderThreeScene(matrix) {
const m = new THREE.Matrix4().fromArray(matrix);
this.camera.projectionMatrix = m;
this.renderer.resetState();
this.renderer.render(this.scene, this.camera);
this.map.triggerRepaint();
},
fetchModelData() {
// 模拟后端返回的数据
this.modelData = [
{
longitude: 经度1,
latitude: 纬度1,
type: 'building',
info: { name: '建筑1', description: '这是一个建筑物' },
},
{
longitude: 经度2,
latitude: 纬度2,
type: 'tree',
info: { name: '树1', description: '这是一棵树' },
},
// 更多数据...
];
},
showModelInfo(modelInfo) {
this.selectedModel = modelInfo;
},
},
};
</script>
<style scoped>
.map-container {
width: 100%;
height: 100vh;
position: relative;
}
.modal {
position: absolute;
top: 20px;
left: 20px;
background: white;
padding: 20px;
border: 1px solid #ccc;
z-index: 1000;
}
</style>