当前位置: 首页 > news >正文

【Cesium 开发实战教程】第六篇:三维模型高级交互:点击查询、材质修改与动画控制

一、开篇衔接​

大家好!第五篇我们实现了空间分析功能,让三维场景具备了 “决策支持” 能力。而在数字孪生、工业可视化等核心场景中,“三维模型” 是核心载体 —— 比如工厂的设备模型、小区的建筑模型、城市的管网模型。仅仅加载模型远远不够,我们还需要与模型 “对话”:点击水泵模型看实时压力数据、电机故障时模型自动变红、点击电梯按钮播放升降动画。​

这些 “模型交互” 是区别于 “静态展示” 的关键,也是 Cesium 在工业场景落地的核心能力。本篇将基于真实工业场景,从 “模型交互原理” 到 “实战代码”,逐步实现三大高频交互功能,并解决新手最易踩的 “模型交互坑”,所有案例均使用开源 glTF 模型(附获取渠道),确保你能跟着复现。​

二、模型交互核心原理(先懂底层逻辑)​

在动手前,需先理解 Cesium 中模型交互的底层逻辑 —— 一切交互都围绕glTF 模型的结构射线检测展开。​

1. glTF 模型的核心结构​

Cesium 支持的 glTF(.gltf/.glb)模型包含三个关键部分,这是交互的基础:​

结构​

作用​

交互关联场景​

节点(Node)​

模型的 “骨骼”,每个节点对应模型的一个部件(如设备的 “电机”“阀门”“底座”)​

点击指定部件(如只点击阀门,不响应底座)​

材质(Material)​

模型的 “皮肤”,定义部件的颜色、纹理、光泽(如金属质感、塑料质感)​

故障时修改颜色(电机从银色变红色)​

动画(Animation)​

模型的 “动作”,定义节点的运动轨迹(如阀门旋转、电梯升降)​

播放 / 暂停动画(开启阀门、电梯上行)​

2. Cesium 模型交互的核心逻辑​

Cesium 通过 **“射线检测 + 模型结构解析”** 实现交互:​

  1. 射线检测:当鼠标点击时,生成从相机到点击位置的 “射线”,判断射线是否与模型相交;​
  2. 获取交互对象:若相交,获取相交的模型实例(Model)、节点(ModelNode)、材质(ModelMaterial);​
  3. 执行交互逻辑:根据需求触发操作(如显示节点属性、修改材质、播放动画)。​

三、实战准备:模型与环境​

1. 测试模型获取(附开源渠道)​

为确保实战可复现,推荐使用以下开源 glTF 模型(工业设备 / 建筑类,带节点和动画):​

  • 工业设备模型:Google Poly(搜索 “pump”“valve”,筛选 glTF 格式);​
  • 建筑动画模型:Sketchfab(搜索 “animated building elevator”,选择免费可商用模型);​
  • 本地模型处理:若模型无节点 / 动画,可用Blender(免费 3D 工具)拆分节点、制作简单动画(如旋转阀门),导出时选择 “glTF 2.0” 格式。​

2. 模型放置路径​

将下载的 glTF 模型(如industrial-pump.glb)放在项目public/models目录下,确保路径正确(如/models/industrial-pump.glb)。​

四、实战场景(从需求到落地)​

以下场景均在CesiumViewer.vue中实现,需先引入新增的 Cesium 类:

// 在script setup顶部添加
import {Model,ModelAnimationClip,ModelNode,ModelMaterial,Ray,ScreenSpaceEventType,Cartesian3,Color,BillboardGraphics,LabelGraphics
} from 'cesium'

场景 1:模型点击交互 —— 查询部件属性​

需求说明​

“加载一台工业水泵模型,点击模型的不同部件(如‘电机’‘进水阀’‘出水阀’),弹出该部件的实时运行参数(如电机转速、阀门开关状态)”,要求只响应指定部件,忽略无关节点(如底座)。​

代码实现

// 1. 加载带节点的水泵模型
const loadPumpModel = () => {// 模型配置(关键:开启节点拾取,否则无法获取点击的部件)const pumpModel = viewer.scene.primitives.add(Model.fromGltf({url: '/models/industrial-pump.glb', // 模型路径modelMatrix: Cesium.Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(116.404, 39.915, 10) // 模型位置(北京,高度10米)),scale: 20, // 缩放比例(根据模型实际大小调整)allowPicking: true, // 允许拾取(必须开启,否则无法点击部件)debugShowBoundingVolume: false // 关闭包围盒显示(调试时可开启)}));// 2. 监听模型加载完成(确保节点已初始化)pumpModel.readyPromise.then((model) => {console.log('水泵模型加载完成,节点列表:', model.nodeNames); // 打印所有节点名称(如"Motor" "InletValve")// 3. 创建“属性弹窗”Entity(初始隐藏)const infoEntity = viewer.entities.add({name: "部件属性弹窗",billboard: new BillboardGraphics({image: "/images/info-panel.png", // 弹窗背景图(public目录下)width: 200,height: 120,show: false}),label: new LabelGraphics({text: "",font: "12px sans-serif",fillColor: Color.BLACK,pixelOffset: new Cartesian2(0, -40), // 文本在弹窗上方show: false})});// 4. 监听鼠标左键点击事件const handler = new ScreenSpaceEventHandler(viewer.scene.canvas);handler.setInputAction((event) => {// 生成射线(从相机到点击位置)const ray = viewer.camera.getPickRay(event.position);if (!ray) return;// 射线检测:获取与模型相交的结果const pickResult = viewer.scene.pickFromRay(ray);if (!pickResult || !pickResult.node) return; // 未点击到模型节点,直接返回// 获取点击的模型节点信息const clickedNode = pickResult.node; // 点击的节点(ModelNode实例)const nodeName = clickedNode.name; // 节点名称(如"Motor")const modelInstance = pickResult.primitive; // 点击的模型实例// 5. 模拟不同部件的实时数据(真实项目从接口获取)const partData = {"Motor": { speed: "2800 RPM", temperature: "45°C", status: "正常" },"InletValve": { openRatio: "100%", pressure: "0.8 MPa", status: "开启" },"OutletValve": { openRatio: "80%", pressure: "0.6 MPa", status: "开启" },"Base": { status: "无数据" } // 底座无数据,不显示弹窗};// 6. 过滤无数据节点,显示弹窗if (partData[nodeName] && partData[nodeName].status !== "无数据") {const data = partData[nodeName];// 组装弹窗文本const infoText = `${nodeName}\n转速:${data.speed || '-'}\n压力:${data.pressure || '-'}\n状态:${data.status}`;// 设置弹窗位置(在点击节点上方10米处)const nodePosition = new Cartesian3();clickedNode.computeWorldMatrix(modelInstance.modelMatrix, new Cartesian3());Cartesian3.multiplyByTranslation(clickedNode.worldMatrix,new Cartesian3(0, 0, 10), // 向上偏移10米nodePosition);// 更新弹窗EntityinfoEntity.position = nodePosition;infoEntity.billboard.show = true;infoEntity.label.text = infoText;infoEntity.label.show = true;} else {// 点击无数据节点,隐藏弹窗infoEntity.billboard.show = false;infoEntity.label.show = false;}}, ScreenSpaceEventType.LEFT_CLICK);// 7. 监听鼠标右键,隐藏弹窗handler.setInputAction(() => {infoEntity.billboard.show = false;infoEntity.label.show = false;}, ScreenSpaceEventType.RIGHT_CLICK);return handler; // 返回事件处理器,方便销毁});return pumpModel;
};// 在onMounted中调用
onMounted(() => {// ...之前的初始化代码(地形、Viewer等)const pumpModel = loadPumpModel();// 组件卸载时销毁模型和事件onUnmounted(() => {if (pumpModel && pumpModel.readyPromise) {pumpModel.readyPromise.then(handler => {handler.destroy(); // 销毁事件处理器});}viewer.scene.primitives.remove(pumpModel); // 移除模型});
});

关键说明​

  1. 开启节点拾取:allowPicking: true是点击部件的前提,否则pickFromRay无法获取节点;​
  2. 节点名称获取:模型加载完成后,通过model.nodeNames打印所有节点名称,需与业务数据的键匹配(如 “Motor” 对应电机数据);​
  3. 弹窗位置计算:通过clickedNode.computeWorldMatrix获取节点的世界坐标,向上偏移避免遮挡模型。​

场景 2:材质动态修改 —— 故障状态可视化​

需求说明​

“当水泵电机温度超过 50°C 时,电机部件从‘银色’变为‘红色’;故障解除后,恢复原色”,支持手动触发故障模拟(用于测试)。​

代码实现

// 在loadPumpModel的readyPromise中扩展(接场景1的代码)
pumpModel.readyPromise.then((model) => {// ...场景1的点击逻辑代码// 8. 动态修改材质的核心函数const updateNodeMaterial = (nodeName, targetColor) => {// 1. 获取目标节点const targetNode = model.getNode(nodeName);if (!targetNode) {console.error(`未找到节点:${nodeName}`);return;}// 2. 获取节点的材质(假设每个节点只有一个材质)const material = model.getMaterial(targetNode.materialIds[0]);if (!material) {console.error(`节点${nodeName}无材质`);return;}// 3. 修改材质颜色(glTF材质的baseColorFactor属性)material.setValue("baseColorFactor", targetColor);// 4. 强制模型重新渲染model.requestRender();};// 9. 模拟故障触发(温度超过50°C)window.triggerMotorFault = () => {console.log("电机温度超过50°C,触发故障");updateNodeMaterial("Motor", Color.RED.withAlpha(1.0)); // 电机变红// 同时更新点击弹窗的状态(真实项目从接口同步)partData["Motor"].status = "故障";partData["Motor"].temperature = "58°C";};// 10. 模拟故障解除window.resetMotorStatus = () => {console.log("电机温度恢复正常,故障解除");updateNodeMaterial("Motor", Color.fromCssColorString("#c0c0c0")); // 恢复银色partData["Motor"].status = "正常";partData["Motor"].temperature = "42°C";};console.log("故障测试:调用 triggerMotorFault() 触发故障,resetMotorStatus() 恢复正常");return handler;
});

操作与原理​

  1. 故障触发:在浏览器控制台调用triggerMotorFault(),电机节点会立即变红,点击电机弹窗显示 “故障” 状态;​
  2. 材质修改原理:glTF 模型的颜色由baseColorFactor(基础颜色因子)控制,通过material.setValue修改该属性,再调用model.requestRender()强制渲染;​
  3. 注意事项:若模型使用纹理(非纯色材质),需先移除纹理或修改baseColorTexture属性(具体需看模型材质结构,可通过console.log(material)查看属性)。​

场景 3:模型动画控制 —— 播放 / 暂停 / 调速​

需求说明​

“加载带动画的电梯模型(包含‘电梯上行’‘电梯下行’‘门打开’‘门关闭’4 个动画),添加控制按钮实现动画的播放、暂停、速度调节,且动画播放时更新电梯位置标签”。​

代码实现

<!-- 在template中添加动画控制按钮 -->
<div class="animation-controls"><button @click="playElevatorAnimation('up')">电梯上行</button><button @click="playElevatorAnimation('down')">电梯下行</button><button @click="playElevatorAnimation('openDoor')">开门</button><button @click="playElevatorAnimation('closeDoor')">关门</button><button @click="pauseElevatorAnimation">暂停</button><input type="range" min="0.5" max="3" step="0.5" v-model="animationSpeed" @change="adjustAnimationSpeed"placeholder="动画速度"><span>{{ animationSpeed }}x</span>
</div><script setup>
// 动画控制相关响应式数据
const animationSpeed = ref(1.0); // 动画速度(0.5x~3x)
let elevatorModel = null; // 电梯模型实例
let currentAnimation = null; // 当前播放的动画// 1. 加载带动画的电梯模型
const loadElevatorModel = () => {elevatorModel = viewer.scene.primitives.add(Model.fromGltf({url: '/models/animated-elevator.glb', // 带动画的电梯模型modelMatrix: Cesium.Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(116.414, 39.915, 0) // 模型位置(北京,贴地)),scale: 5,allowPicking: true}));// 2. 监听模型加载完成(获取动画列表)elevatorModel.readyPromise.then((model) => {console.log('电梯模型加载完成,动画列表:', model.activeAnimations._clips.map(c => c.name));// 3. 创建电梯位置标签(显示当前楼层)const floorLabel = viewer.entities.add({name: "电梯楼层标签",position: Cartesian3.fromDegrees(116.414, 39.915, 5), // 标签在电梯上方5米label: new LabelGraphics({text: "当前楼层:1",font: "16px sans-serif",fillColor: Color.WHITE,backgroundColor: Color.BLACK.withAlpha(0.7),showBackground: true,pixelOffset: new Cartesian2(0, -20)})});// 4. 监听动画播放进度(更新楼层标签)model.activeAnimations.progressUpdated.addEventListener((animation) => {if (animation.name === 'up') {// 上行动画:进度0→1对应楼层1→10const floor = Math.ceil(1 + animation.progress * 9);floorLabel.label.text = `当前楼层:${floor}`;} else if (animation.name === 'down') {// 下行动画:进度0→1对应楼层10→1const floor = Math.ceil(10 - animation.progress * 9);floorLabel.label.text = `当前楼层:${floor}`;}});return model;});return elevatorModel;
};// 5. 动画播放函数
const playElevatorAnimation = (animationName) => {if (!elevatorModel || !elevatorModel.ready) return;const model = elevatorModel;// 先暂停当前动画if (currentAnimation) {model.activeAnimations.pause(currentAnimation);}// 查找目标动画const targetAnimation = model.activeAnimations._clips.find(clip => clip.name === animationName);if (!targetAnimation) {alert(`未找到动画:${animationName}`);return;}// 播放动画(循环1次)currentAnimation = model.activeAnimations.add({clip: targetAnimation,loop: ModelAnimationLoop.NONE, // 不循环speedup: animationSpeed.value, // 播放速度startOffset: 0, // 从开头播放stopOffset: targetAnimation.duration // 播放完整时长});
};// 6. 动画暂停函数
const pauseElevatorAnimation = () => {if (currentAnimation && elevatorModel.ready) {elevatorModel.activeAnimations.pause(currentAnimation);}
};// 7. 动画速度调节函数
const adjustAnimationSpeed = () => {if (currentAnimation && elevatorModel.ready) {currentAnimation.speedup = animationSpeed.value;}
};// 在onMounted中调用(注释掉水泵模型,单独测试电梯)
onMounted(() => {// ...之前的初始化代码// const pumpModel = loadPumpModel();elevatorModel = loadElevatorModel(); // 加载电梯模型onUnmounted(() => {viewer.scene.primitives.remove(elevatorModel); // 移除电梯模型});
});
</script><style scoped>
/* 动画控制按钮样式(固定在页面下方) */
.animation-controls {position: absolute;bottom: 20px;left: 50%;transform: translateX(-50%);z-index: 100;display: flex;gap: 10px;align-items: center;padding: 10px;background: rgba(0, 0, 0, 0.5);border-radius: 8px;
}.animation-controls button {padding: 6px 12px;background: white;border: none;border-radius: 4px;cursor: pointer;
}.animation-controls input {width: 100px;
}.animation-controls span {color: white;font-size: 14px;
}
</style>

关键说明​

  1. 动画列表获取:模型加载后,通过model.activeAnimations._clips获取所有动画(需确保模型导出时包含动画);​
  2. 动画循环控制:loop: ModelAnimationLoop.NONE表示播放 1 次,ModelAnimationLoop.REPEAT表示循环播放;​
  3. 进度监听:通过progressUpdated事件获取动画进度(0→1),实现 “进度→楼层” 的映射,更新标签。​

五、常见问题与解决方案(真实开发踩坑)​

1. 问题 1:点击模型无响应,无法获取节点​

  • 原因 1:未开启allowPicking: true,模型默认不允许拾取;​
  • 原因 2:模型节点未正确导出(如 Blender 导出时未勾选 “导出节点”);​
  • 原因 3:射线检测范围错误(点击位置不在模型包围盒内);​
  • 解决方案:​
    • ​​​​​​​确认Model.fromGltf中allowPicking: true;​
    • 用 Blender 重新导出模型,勾选 “Include Nodes”;​
    • 调试时开启debugShowBoundingVolume: true,查看模型包围盒,确保点击在包围盒内。​

2. 问题 2:材质修改不生效​

  • 原因 1:模型材质属性名称错误(非baseColorFactor,如diffuseColor);​
  • 原因 2:材质使用纹理(baseColorTexture),纯色修改被纹理覆盖;​
  • 原因 3:未调用model.requestRender()强制渲染;​
  • 解决方案:
    • 通过console.log(material)查看材质属性,替换正确的属性名;​
    • 若有纹理,先移除纹理:material.setValue("baseColorTexture", undefined);​
    • 修改材质后必须调用model.requestRender()。​

3. 问题 3:动画播放卡顿或不流畅​

  • 原因 1:模型动画帧数过高(如每秒 60 帧),渲染压力大;​
  • 原因 2:同时播放多个动画,CPU/GPU 负载过高;​
  • 原因 3:动画速度过快(speedup超过 3);​
  • 解决方案:​
    • 用 Blender 简化动画,降低帧数(如每秒 24 帧);​
    • 避免同时播放多个动画,播放新动画前暂停旧动画;​
    • 限制speedup最大值为 3,避免过度加速。​

六、总结与下一篇预告​

本篇我们聚焦三维模型的 “交互能力”,实现了真实工业场景的核心需求:​

  1. 模型点击查询:精准定位部件,显示实时运行参数;​
  2. 材质动态修改:故障状态可视化,直观展示设备异常;​
  3. 动画控制:播放 / 暂停 / 调速,模拟设备动作(电梯、阀门)。​

下一篇预告:《Cesium 项目实战:从零搭建数字孪生工厂最小系统》—— 前面我们学了单个功能(底图、Entity、空间分析、模型交互),下一篇将 “整合所有知识点”,从零搭建一个包含 “厂区底图、设备模型、实时数据、空间分析” 的数字孪生工厂最小系统,涵盖项目结构设计、数据对接、性能优化,让你具备完整项目的开发能力。

http://www.dtcms.com/a/397998.html

相关文章:

  • 英雄联盟视频网站源码做产品设计之前怎么查资料国外网站
  • Vue3-接入飞书H5应用
  • 四川省建设厅网站川北医学院广告网站怎么建设
  • 七彩喜智慧养老:科技向善,让晚年生活绽放“喜”悦之光
  • 模型驱动的 AI Agent架构:亚马逊云科技的Strands框架技术深度解析
  • 【数据结构】——外部排序(K路归并)
  • 【观成科技】活跃黑产团伙“黑猫”攻击武器加密通信分析
  • 高斯过程(Gaussian Process)回归:一种贝叶斯非参数方法
  • 微算法科技(NASDAQ MLGO)创新基于账户加权图与后量子密码学的区块链
  • 中国银行信息科技岗位笔试
  • WXML 编译错误修复总结
  • 怎么给网站wordpress游戏网站策划书
  • Halcon学习--(3)图像阈值处理
  • 知识导航新体验:Perplexica+cpolar 24小时智能服务
  • 全面解析Redis分布式锁
  • 自由学习记录(103)
  • 大模型部署基础设施搭建 - Dify
  • 没有网站怎么推广企业建设网站能否报销
  • 天津道路运输安全员考试报名条件
  • dbpystream webapi: 从阿里云福州站点到上海站点的迁移之旅
  • 解读 2025 《可信数据空间 使用控制技术要求》
  • Java多线程编程:阻塞队列、wait-notify锁协调机制、线程安全[条件产生渡送执行]
  • 绕过UAC开机自启动程序方法
  • 东莞市南城装饰工程东莞网站建设系统门窗品牌排行前十名
  • Nginx负载均衡算法与IP透传、跨域实战指南
  • asp.net不适合做网站凡客建设网站稳定吗
  • Vue中的路由细节
  • 高防 IP 是如何帮助数藏行业防刷
  • 将深度学习与Spring Boot集成:使用DL4J构建企业级AI应用的完整指南
  • 《UE5_C++多人TPS完整教程》学习笔记57 ——《P59 脚步声与跳跃声(Footstep And Jump Sounds)》