【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 类:

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

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

需求说明​

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

代码实现

javascript 复制代码
// 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
        );
        // 更新弹窗Entity
        infoEntity.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 时,电机部件从'银色'变为'红色';故障解除后,恢复原色",支持手动触发故障模拟(用于测试)。​

代码实现

javascript 复制代码
// 在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 个动画),添加控制按钮实现动画的播放、暂停、速度调节,且动画播放时更新电梯位置标签"。​

代码实现

javascript 复制代码
<!-- 在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→10
        const floor = Math.ceil(1 + animation.progress * 9);
        floorLabel.label.text = `当前楼层:${floor}`;
      } else if (animation.name === 'down') {
        // 下行动画:进度0→1对应楼层10→1
        const 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、空间分析、模型交互),下一篇将 "整合所有知识点",从零搭建一个包含 "厂区底图、设备模型、实时数据、空间分析" 的数字孪生工厂最小系统,涵盖项目结构设计、数据对接、性能优化,让你具备完整项目的开发能力。

相关推荐
FserSuN3 小时前
React 标准 SPA 项目 入门学习记录
前端·学习·react.js
蜡笔小电芯3 小时前
【HTML】 第一章:HTML 基础
前端·html
正义的大古3 小时前
OpenLayers地图交互 -- 章节十:拖拽平移交互详解
前端·javascript·vue.js·openlayers
JosieBook3 小时前
【SpringBoot】27 核心功能 - Web开发原理 - Spring MVC中的定制化原理
前端·spring boot·spring
IT_陈寒3 小时前
Vue 3.4性能优化实战:这5个技巧让我的应用加载速度提升了300%!🚀
前端·人工智能·后端
小墨宝3 小时前
umijs 4.0学习 - umijs 的项目搭建+自动化eslint保存+项目结构
开发语言·前端·javascript
QYR行业咨询3 小时前
2025-2031全球与中国隧道照明灯具市场现状及未来发展趋势
前端·后端
QYR行业咨询3 小时前
减振扣件市场现状及未来发展趋势-QYResearch
前端
QYR行业咨询3 小时前
2025-2031全球与中国充气橡胶护舷市场现状及未来发展趋势
前端