前言:三维模型基础展示平台,首先需要绘制一个动态交互的地铁线路图,尝试了 ECharts 等可视化方案后,都不是很满意(想要类似于高德地图地铁图的效果)就自己花了一些时间做出来这个交互式地铁线路图以及三维模型的渲染交互,本文将分享技术实现细节与踩坑经验,希望为有类似需求的开发者提供参考。欢迎交流指正,共同优化交互体验!
效果图
这是完整的地铁线路图效果,参考高德地图中地铁图样式来绘制的,采用的是svg来绘制线路支持拖动、缩放等功能。
准备数据
采用的是高德地图中的数据,可以直接在高德地图的接口直接拿。地址,直接复制内容,保存到js文件中。所需要的地铁线路、站点数据都在里面。
处理线路数据
js
// 处理线路数据
const processedLines = computed(() => {
return rawData.l.map((line) => ({
// 对rawData.l 数组中的每一项进行处理
id: line.li.split("|")[0],
// cl为线路的颜色,将cl值前面加上#符号,形成一个十六进制的颜色
color: `#${line.cl}`,
// c为坐标数据,对数组中的每一项进行处理,最终得到一个二位数组
points: line.c.map((p) => p.split(" ").map(Number)),
// 调用detectLineDirection函数,计算线路的方向
direction: detectLineDirection(line.c),
}));
});
processedLines
是一个计算属性,它会对 rawData.l
数组中的每一项进行处理,最终返回一个新的数组,数组中的每一项是一个包含 id
、color
、points
和 direction
属性的对象。
js
const detectLineDirection = (points) => {
const start = points[0].split(" ").map(Number);
const end = points[points.length - 1].split(" ").map(Number);
// 计算线路整体走向
const dx = end[0] - start[0];
const dy = end[1] - start[1];
// 计算线条角度
const angle = (Math.atan2(dy, dx) * 180) / Math.PI;
if (Math.abs(angle) < 45) return "horizontal";
if (Math.abs(angle) > 135) return "horizontal";
return "vertical";
};
detectLineDirection
的函数,其主要功能是依据给定的一系列点来判断线条的走向,具体会判断为水平(horizontal
)或者垂直(vertical
)走向。
js
// 路径生成
const generatePath = (points) => {
return points
.map(([x, y], index) => `${index === 0 ? "M" : "L"} ${x} ${y}`)
.join(" ");
};
generatePath
的函数,其主要功能是将一组坐标点转换为 SVG 路径数据字符串。
这样地铁线路的就绘制完成了
处理站点数据
js
const allStations = computed(() => {
// 创建一个 Map 对象,用于存储站点信息
const stationMap = new Map();
// 遍历每条线路
rawData.l.forEach((line) => {
const lineId = line.li.split("|")[0];
const direction = detectLineDirection(line.c);
// 遍历线路上的每个站点
line.st.forEach((station, index) => {
const key = station.si;
const existing = stationMap.get(key);
if (existing) {
existing.lines.push(lineId);
} else {
// nc为站点坐标
const [x, y] = station.nc;
const newStation = {
...station,
x, // 直接使用解析后的 nc 的 x 坐标
y, // 直接使用解析后的 nc 的 y 坐标
lines: [lineId], // 线路id
mainLine: lineId, // 主线路id
direction, // 线路方向
order: index,
};
stationMap.set(key, newStation);
}
});
});
return Array.from(stationMap.values());
});
allStations
,它的作用是根据 rawData
中的线路信息,整理出所有站点的信息,并将这些站点信息存储在一个数组中。 遍历 rawData
里的每条线路,对每条线路上的站点进行处理。若站点已存在于 stationMap
中,就把当前线路 ID 添加到该站点的 lines
数组里;若站点不存在,就创建一个新的站点对象,将其添加到 stationMap
中。最后把 stationMap
中的所有站点对象转换为数组并返回。
js
const labelStyle = (station) => ({
fill: station.t === "1" ? "#333" : lineColors[station.mainLine],
fontSize: station.t === "1" ? "14px" : "12px",
fontWeight: station.t === "1" ? "700" : "500",
stroke: station.t === "1" ? "rgba(255,255,255,0.9)" : "white",
"stroke-width": station.t === "1" ? "2.5px" : "2px",
});
该方法接收一个 station
对象作为参数,根据 station
对象的 t
属性值来动态生成站点标签的样式。t
属性区分站点的类型,例如换乘站和普通站点。station
是一个包含站点信息的对象。
站点数据处理完成,接下来就是实现SVG 视图框的拖动和缩放交互功能。
viewBox
响应式对象
php
const viewBox = reactive({
x: 0,
y: 0,
width: 2000,
height: 2000,
});
const isDragging = ref(false); // 标记当前是否处于拖动状态
const startPos = reactive({ x: 0, y: 0 }); // 存储拖动开始时鼠标的初始位置
viewBox
用于存储 SVG 视图框的位置和大小信息。x
和 y
表示视图框的左上角坐标,width
和 height
表示视图框的宽度和高度。
ini
// 拖动开始事件
const startDrag = (e) => {
isDragging.value = true;
startPos.x = e.clientX;
startPos.y = e.clientY;
};
// 拖动结束事件
const endDrag = () => {
isDragging.value = false;
};
// 鼠标移动事件,只有当isDragging为true时才会执行
// 计算鼠标在x和y方向上的移动距离,并根据视图框的当前宽度和高度进行缩放。
// 更新viewBox的x和y坐标,实现视图框的拖动效果
const handleMove = (e) => {
if (!isDragging.value) return;
const dx = (e.clientX - startPos.x) * (viewBox.width / 2000);
const dy = (e.clientY - startPos.y) * (viewBox.height / 2000);
viewBox.x -= dx;
viewBox.y -= dy;
startPos.x = e.clientX;
startPos.y = e.clientY;
};
// 缩放功能,实现视图框的缩放
// 根据鼠标位置和缩放前后的宽度、高度差,更新viewBox的x和y坐标,实现基于鼠标位置的精准缩放
// 最后更新 `viewBox` 的 `width` 和 `height`。
const handleZoom = (e) => {
const zoomFactor = e.deltaY > 0 ? 1.2 : 0.8;
const rect = e.target.getBoundingClientRect();
// 基于鼠标位置的精准缩放
const mouseX = (e.clientX - rect.left) / rect.width;
const mouseY = (e.clientY - rect.top) / rect.height;
const newWidth = viewBox.width * zoomFactor;
const newHeight = viewBox.height * zoomFactor;
viewBox.x += (viewBox.width - newWidth) * mouseX;
viewBox.y += (viewBox.height - newHeight) * mouseY;
viewBox.width = newWidth;
viewBox.height = newHeight;
};
以上就是数据处理和视图移动、缩放部分。下面是html部分
html
<template>
<div class="container" ref="container">
<svg
:viewBox="`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`"
@mousedown="startDrag"
@mouseup="endDrag"
@mousemove="handleMove"
@wheel.prevent="handleZoom"
>
<!-- 地铁线路绘制 -->
<g v-for="line in processedLines" :key="line.id">
<path
:d="generatePath(line.points)"
:stroke="line.color"
stroke-width="6"
fill="none"
stroke-linecap="round"
/>
</g>
<!-- 地铁站点及标签 -->
<g
v-for="station in allStations"
:key="station.si"
class="station-circle"
@click="handleClick(station)"
>
<!-- 站点图形 -->
<circle
:cx="station.p[0]"
:cy="station.p[1]"
:r="station.t === '1' ? 8 : 5"
fill="#fff"
:stroke="station.t === '1' ? lineColors[station.mainLine] : '#000'"
stroke-width="2"
/>
<!-- 标签 -->
<text
:x="station.nc[0]"
:y="station.nc[1]"
class="station-label"
:style="labelStyle(station)"
text-anchor="middle"
dominant-baseline="central"
>
{{ station.n }}
</text>
</g>
</svg>
</div>
</template>
这样一个的地铁线路图就已经绘制出来了,也可以在里面加上线路标识、线路点击高亮等功能,我这边主要是对地铁模型进行展示,所以暂时只做了一个简单的拖拽、缩放功能。
接下来就是处理三维模型部分,使用的是three.js进行渲染。
搭建场景
定义所需要的变量
js
const threeRef = ref(null);
// 场景
const scene = new THREE.Scene();
// 渲染器
const renderer = new THREE.WebGLRenderer({
antialias: true,
logarithmicDepthBuffer: true, // 启用对数深度缓冲区
});
// 相机
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
// 控制器
const controls = new OrbitControls(camera, renderer.domElement);
// 模型骨架
let skeletonHelper = null;
// 文件加载器类型
const fileLoaderMap = {
glb: new GLTFLoader(),
fbx: new FBXLoader(loadingManager),
gltf: new GLTFLoader(),
obj: new OBJLoader(loadingManager),
stl: new STLLoader(),
};
初始化场景
js
const init = () => {
width = threeRef.value.offsetWidth;
height = threeRef.value.offsetHeight;
// 初始化相机
// camera = ;
camera.position.set(
cameraParams.position.x,
cameraParams.position.y,
cameraParams.position.z
);
camera.lookAt(
new THREE.Vector3(
cameraParams.lookAt.x,
cameraParams.lookAt.y,
cameraParams.lookAt.z
)
);
scene.add(camera);
camera.near = 0.1; // 根据场景调整
camera.far = 1000; // 根据场景调整
camera.updateProjectionMatrix(); // 更新相机投影矩阵
// 创建环境光
const ambientLight = new THREE.AmbientLight(0xffffff, 1.0);
ambientLight.visible = true;
scene.add(ambientLight);
// 创建平行光
const directionalLight = new THREE.DirectionalLight(0xffffff, 5);
directionalLight.position.set(-1.44, 2.2, 1);
directionalLight.castShadow = true;
directionalLight.visible = true;
scene.add(directionalLight);
// 创建平行光辅助线
const directionalLightHelper = new THREE.DirectionalLightHelper(
directionalLight,
0.3
);
directionalLightHelper.visible = false;
scene.add(directionalLightHelper);
// 模型平面
const geometry = new THREE.PlaneGeometry(40, 40);
const groundMaterial = new THREE.MeshStandardMaterial({
color: "#404040",
metalness: 0.3,
roughness: 0.8,
});
const planeGeometry = new THREE.Mesh(geometry, groundMaterial);
planeGeometry.rotation.x = -Math.PI / 2;
planeGeometry.position.set(0, -1.2, 0);
// 修改原代码中的 visible 属性
// 让地面接收阴影
planeGeometry.receiveShadow = true;
planeGeometry.visible = false;
// 开启渲染器的阴影映射
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap; // 或者 THREE.PCFShadowMap
// 确保地面接收阴影并允许模型投射阴影
scene.add(planeGeometry);
renderer.setSize(width, height);
threeRef.value.appendChild(renderer.domElement);
controls.enablePan = true;
controls.enableDamping = true;
controls.dampingFactor = 0.05; // 增加阻尼感
controls.minDistance = 5; // 允许更近距离观察
controls.rotateSpeed = 0.5; // 降低旋转速度
controls.target = new THREE.Vector3(0, 0, 0);
controls.minPolarAngle = 0; // 最小俯仰角
controls.maxPolarAngle = Math.PI / 2 - 0.1; // 最大俯仰角(避免看向地面)
renderer.domElement.addEventListener("click", (event) => {
// 将鼠标坐标转换为归一化的设备坐标
const { offsetX, offsetY } = event;
const x = (offsetX / width) * 2 - 1;
const y = -(offsetY / height) * 2 + 1;
// 创建一个用于存储鼠标位置的向量
const mouse = new THREE.Vector2(x, y);
// 创建一个射线投射器
const raycaster = new THREE.Raycaster();
// 设置射线投射器从相机通过鼠标位置发射射线
raycaster.setFromCamera(mouse, camera);
// 计算射线与场景中物体的相交情况
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length > 0) {
// 获取相交的物体
let intersectedObject = intersects[0].object;
console.log(intersects[0].point);
// // 获取点击在模型上的世界坐标
// const worldPosition = intersects[0].point;
// // 将世界坐标转换为局部坐标
// const localPosition = new THREE.Vector3().copy(worldPosition);
// intersectedObject.worldToLocal(localPosition);
}
});
};
js
const animate = () => {
requestAnimationFrame(animate);
const delta = clock.getDelta(); // 获取时间差
TWEEN.update(); // 更新 Tween 动画,后面有用到
controls.update(); // 更新控制器
renderer.render(scene, camera);
};
使用 requestAnimationFrame
来创建一个动画循环。
加载模型
js
// 提取文件格式
const getFileFormat = (filePath) => {
const dotIndex = filePath.lastIndexOf(".");
return dotIndex !== -1 ? filePath.substring(dotIndex + 1).toLowerCase() : null;
};
// 设置 DRACO 加载器
const setupDracoLoader = () => {
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath(`draco/gltf/`);
dracoLoader.setDecoderConfig({ type: "js" });
dracoLoader.preload();
return dracoLoader;
};
// 设置模型材质和属性
const setupModelProperties = (model, filePath) => {
model.visible = true;
model.userData.filePath = filePath;
model.scale.set(1, 1, 1);
model.traverse((child) => {
if (child.isMesh) {
setupMeshProperties(child);
}
});
model.name = extractFloorInfo(filePath);
originalPositions.set(model.uuid, model.position.clone());
floors.value.push(model);
scene.add(model);
return model;
};
// 设置网格材质和属性
const setupMeshProperties = (mesh) => {
mesh.material.polygonOffset = true;
mesh.material.polygonOffsetFactor = 1;
mesh.material.polygonOffsetUnits = 1;
mesh.castShadow = true;
mesh.receiveShadow = true;
if (mesh.material.map) {
mesh.material.map.anisotropy = 4;
}
};
// 设置骨骼辅助器
const setupSkeletonHelper = (model) => {
if (model.isSkinnedMesh) {
const skeletonHelper = new THREE.SkeletonHelper(model);
model.traverse((child) => {
if (child.isMesh) {
setupMeshProperties(child);
}
});
scene.add(skeletonHelper);
}
};
// 加载模型
const setModel = (filePath) => {
return new Promise((resolve, reject) => {
console.log("Loading file:", filePath);
const fileType = getFileFormat(filePath);
let loader;
if (["glb", "gltf"].includes(fileType)) {
const dracoLoader = setupDracoLoader();
loader = new GLTFLoader().setDRACOLoader(dracoLoader);
} else {
loader = fileLoaderMap[fileType];
}
if (!loader) {
const errorMessage = `不支持的文件类型: ${fileType}`;
console.error(errorMessage);
reject(new Error(errorMessage));
return;
}
loader.load(
filePath,
(result) => {
let model;
switch (fileType) {
case "glb":
case "gltf":
model = markRaw(result.scene);
break;
case "fbx":
model = markRaw(result);
break;
case "obj":
model = markRaw(result.scene);
break;
case "stl":
const material = new THREE.MeshStandardMaterial();
const mesh = new THREE.Mesh(result, material);
model = mesh;
break;
default:
const errorMessage = `不支持的文件类型: ${fileType}`;
console.error(errorMessage);
reject(new Error(errorMessage));
return;
}
if (model) {
setupModelProperties(model, filePath);
setupSkeletonHelper(model);
}
resolve(model);
},
undefined,
(error) => {
console.error("Error loading model:", error);
reject(error);
}
);
});
};
- 文件格式提取, 通过
lastIndexOf
方法找到文件路径中最后一个.
的索引位置。若存在.
,则使用substring
方法提取.
后面的部分作为文件格式,将其转换为小写后存储在fileType
变量中。 - 加载器设置,根据文件类型选择合适的加载器。若文件类型是
glb
或gltf
,则创建一个DRACOLoader
用于处理 Draco 压缩的文件,设置解码器路径和配置并预加载,然后使用GLTFLoader
并关联DRACOLoader
。若文件类型不是glb
或gltf
,则从 上方定义fileLoaderMap
对象中获取对应的加载器。 - 模型加载和处理,
load
方法接收三个参数:文件路径、加载成功的回调函数、加载进度的回调函数(这里未使用,传入undefined
)和加载失败的回调函数。在加载成功的回调函数中,根据文件类型对result
进行处理,使用markRaw
方法将模型转换为原始对象,并存储在model
变量中。对于stl
文件创建一个THREE.MeshStandardMaterial
材质,并将其与加载的几何体创建一个网格对象作为模型。在加载失败的回调函数中,打印错误信息并拒绝 Promise。 - 在模型加载和处理完成后,通过
resolve
方法返回处理后的模型,并添加到场景中。
自动调整视角
js
const autoAdjustCamera=()=> {
// 计算所有可见模型的联合包围盒
const bbox = new THREE.Box3();
let hasVisibleModel = false;
floors.value.forEach((model) => {
if (model.visible) {
model.updateWorldMatrix(true, true); // 关键!更新模型矩阵
const modelBox = new THREE.Box3().setFromObject(model);
bbox.union(modelBox);
hasVisibleModel = true;
}
});
if (!hasVisibleModel) return;
// 计算包围盒中心和尺寸
const center = bbox.getCenter(new THREE.Vector3());
const size = bbox.getSize(new THREE.Vector3());
// 动态计算相机距离(增加30%余量)
const maxDimension = Math.max(size.x, size.y, size.z);
const fovRad = camera.fov * (Math.PI / 180);
const distance = (maxDimension / (2 * Math.tan(fovRad / 2))) * 1.3;
// 设置相机位置和方向
camera.position.set(
center.x,
center.y + size.y * 0.3, // 添加高度偏移
center.z + distance
);
camera.lookAt(center);
// 更新控制器
controls.target.copy(center);
controls.update();
// 动态调整投影平面
camera.near = distance * 0.1;
camera.far = distance * 10;
camera.updateProjectionMatrix();
}
autoAdjustCamera
的方法,首先会遍历场景中的所有楼层模型,找出可见的模型并计算它们的联合包围盒。接着,根据这个包围盒的中心和尺寸,动态计算相机的合适距离,然后设置相机的位置和方向,使其能够聚焦在包围盒的中心。最后,更新相机控制器的目标位置和相机的投影平面,以保证渲染效果的正确性。
相机平滑移动和视角调整
js
const flyToCamera = (targetPosition, targetLookAt) => {
return new Promise((resolve) => {
new TWEEN.Tween({
x: camera.position.x,
y: camera.position.y,
z: camera.position.z,
tx: controls.target.x,
ty: controls.target.y,
tz: controls.target.z,
})
.to(
{
x: targetPosition.x,
y: targetPosition.y,
z: targetPosition.z,
tx: targetLookAt.x,
ty: targetLookAt.y,
tz: targetLookAt.z,
},
cameraConfig.flyDuration
)
.easing(TWEEN.Easing.Quadratic.InOut)
.onUpdate((obj) => {
camera.position.set(obj.x, obj.y, obj.z);
controls.target.set(obj.tx, obj.ty, obj.tz);
controls.update();
})
.onComplete(resolve)
.start();
});
};
flyToCamera
方法接收两个参数 targetPosition
和 targetLookAt
,分别代表相机要移动到的目标位置和相机要注视的目标点。方法返回一个 Promise,当相机的移动和视角调整动画完成时,Promise 会被 resolve。
按楼层展示模型
js
const floors = ref([]); // 楼层数据
const active = ref(2);
// 相机动画参数
const cameraConfig = reactive({
isAnimating: false,
flyDuration: 800,
});
const onFloorClick = async (model, index) => {
if (cameraConfig.isAnimating) return;
active.value = index;
// 如果在展开模式,先收起楼层
if (spreadConfig.isSpreadMode) {
await toggleFloors();
}
if (Array.isArray(model)) {
floors.value.forEach((floor) => {
floor.visible = true;
});
} else {
// 显示点击楼层,隐藏其他
floors.value.forEach((floor) => {
floor.visible = floor === model;
});
// 精确定位到该楼层
const bbox = new THREE.Box3().setFromObject(model);
const center = bbox.getCenter(new THREE.Vector3());
// 计算最佳观察角度
const targetPosition = new THREE.Vector3(
center.x + 20,
center.y + 100,
center.z + 90
);
cameraConfig.isAnimating = true;
await flyToCamera(targetPosition, center);
cameraConfig.isAnimating = false;
}
};
onFloorClick
用于处理楼层的点击事件。当点击某个模型时,会根据当前的相机动画状态、展开模式以及模型的类型(数组或单个对象)来执行不同的操作,包括显示或隐藏楼层、调整相机视角等。
模型展开、收起操作
js
const spreadConfig = reactive({
isSpreadMode: false, // 默认未展开
spreadIntensity: 20, // 楼层展开的间距
baseHeight: 0,
animationDuration: 800, // 动画的持续时间
originalVisibilities: null, // 保存展开前的可见状态
});
const toggleFloors = (index) => {
if (cameraConfig.isAnimating) return;
active.value = index;
spreadConfig.isSpreadMode = !spreadConfig.isSpreadMode;
// 保存当前可见状态(仅在进入展开模式时)
if (spreadConfig.isSpreadMode) {
spreadConfig.originalVisibilities = new Map();
floors.value.forEach((floor) => {
spreadConfig.originalVisibilities.set(floor.uuid, floor.visible);
floor.visible = true; // 显示所有楼层
});
}
toggleFloors
方法来切换楼层的展开或收起状态。如果相机正在进行动画cameraConfig.isAnimating
为 true
,则直接返回,不执行后续操作,避免在相机动画过程中触发楼层展开或收起操作。将 spreadConfig.isSpreadMode
的值取反,实现展开模式和收起模式的切换。
以上就是关于模型相关的所有操作,包括加载模型、切换楼层展示、模型的展开收起动画等。在前面的地铁线路中加上模型path项,绑定到站点上,就可以通过点击前面绘制的地铁站点来展示对应的站点模型,这样就是一个完整的功能。
最后一个冷笑话: (背景切换为星空下的禅院,青年程序员若有所思地望向远方) 青年:"原来代码的世界里,佛曰的 ' 因果 ' 就是语法糖啊..."
禅师:"错!那是你没写 try-catch。" (突然响起电子音效,三维模型从莲花座下升起)
系统提示:检测到逻辑漏洞!建议立即执行 "南无阿弥陀佛.debug ()"
(画面定格在青年程序员掏出键盘的瞬间,右下角弹出一行小字)
------ 程序员修行指南:代码即佛法,bug 即心魔