低成本创建数字孪生场景-开发篇

介绍

本文承接《低成本创建数字孪生场景-数据篇》,获取到了数据之后就是愉快的开发环节,我们用到了开源项目CesiumJS做为GIS技术框架。

CesiumJS项目从创建至今已经有12年历史,提供了创建3D地球和2D地图的强大功能,它支持多种地图数据源,可以创建复杂的3D地形和城市模型。CesiumJS的功能强大,但入门难度比较高,需要提前了解很多概念和设计理念,为方便理解本案例仅仅使用其提供的一些基础功能。

需求说明

为了搭建1个简易的山区小乡镇场景,我们首先梳理下需求,把任务分解好。

  1. 在底图上叠加各种图层
    • 支持叠加地形图层、3DTiles图层、数据图层
    • 支持多种方式分发图层数据
  2. 鼠标与图层元素的交互
    • 鼠标移动时,使用屏幕事件处理器监听事件,获取当前屏幕坐标
    • 如果已经有高亮的元素,将其恢复为正常状态
    • 以当前屏幕坐标为起点发送射线,获取射线命中的元素,如果有命中的元素就高亮它
    • 鼠标点击时使用屏幕事件处理器获取命中元素,如果命中了,就判断元素是否描边状态,有则取消描边,没有则增加描边轮廓
  3. 加载Gltf等其他模型
    • 模型与其他图层元素一样,可以被光标拾取
    • 模型支持播放自带动画

准备工作

数据分发服务

当前案例涉及的图层数据以文件类为主,为方便多处使用,需要将图层的服务独立部署,这里有两个方案:

  1. 自行搭建静态文件服务器,网上搜一个常用的node.js静态服务脚本即可

  2. 把文件放到cesium ion上,如果你要使用cesium ion资产,需要注意配好defaultAccessToken,具体调用方式看下文的代码实现

安装依赖

以下为本案例的前端工程使用的核心框架版本

依赖 版本
vue ^3.2.37
vite ^2.9.14
Cesium ^1.112.0

代码实现

  1. 地图基本场景,本示例使用vite+vue3开发,html非常简单,只需要一个

    标签即可,在cesiumjs中以Viewer为起点调用其他控件,因此我们实例化一个Cesium.viewer, 这里面有非常多配置参数,详细请看开发文档

    jsx 复制代码
    import * as Cesium from 'cesium'
    import 'cesium/Build/Cesium/Widgets/widgets.css'
    
    Cesium.Ion.defaultAccessToken = '可以把一些GIS资产放到Cesium ION上托管,Tokenw为调用凭证'
    
    // 地图中心
    const center = [1150, 29]
    
    // cesium实例
    let viewer = null
    
    // 容器
    const cesiumContainer = ref(null)
    
    onMounted(async () => {
      await init()
    })
    
    async function init() {
      viewer = new Cesium.Viewer(cesiumContainer.value, {
        timeline: true, //显示时间轴
        animation: true, //开启动画
        sceneModePicker: true, //场景内容可点击
        baseLayerPicker: true, //图层可点击
        infoBox: false, // 自动信息弹窗
        shouldAnimate: true // 允许播放动画
      })
      // 初始化镜头视角
      restoreCameraView()
    
      // 开启地形深度检测
      viewer.scene.globe.depthTestAgainstTerrain = true
      // 开启全局光照
      viewer.scene.globe.enableLighting = true
      // 开启阴影
      viewer.shadows = true
    
    })
    
    // 设置初始镜头
    function restoreCameraView(){
    	viewer.camera.flyTo({
        destination: Cesium.Cartesian3.fromDegrees(center[0], center[1], 0),
        orientation: {
          heading: Cesium.Math.toRadians(0), // 相机的方向
          pitch: Cesium.Math.toRadians(-90), // 相机的俯仰角度
          roll: 0 // 相机的滚动角度
        }
      })
    }
    
    // 加载地形图层
    async function initTerrainLayer() {
      const tileset = await Cesium.CesiumTerrainProvider.fromUrl(
        'http://localhost:9003/terrain/c8Wcm59W/',
        {
          requestWaterMask: true,
          requestVertexNormals: false
        }
      )
      viewer.terrainProvider = tileset
    }
  2. 在地图上叠加地形图层,图层数据可以自行部署

    jsx 复制代码
    // 方法1: 加载本地地形图层
    async function initTerrainLayer() {
      const tileset = await Cesium.CesiumTerrainProvider.fromUrl(
        'http://localhost:9003/terrain/c8Wcm59W/',
        {
          requestWaterMask: true,
          requestVertexNormals: false
        }
      )
      viewer.terrainProvider = tileset
    }
    
    // 方法2: 加载Ion地形图层
    async function initTerrainLayer() {
      const tileset = await Cesium.CesiumTerrainProvider.fromIonAssetId(1,{
          requestVertexNormals: true
        }
      )
      viewer.terrainProvider = tileset
    }
  3. 加载3DTiles图层,与地形图层类似,换成了Cesium3DTileset类。需要注意使用url加载需要自行解决跨域问题

    jsx 复制代码
    const tileset = await Cesium.Cesium3DTileset.fromUrl(
      'http://localhost:9003/model/tHuVnsJXZ/tileset.json',
      {}
    )
    // 将图层加入到场景
    viewer.scene.primitives.add(tileset)
    
    // 适当调整图层位置
    const translation = getTransformMatrix(tileset, { x: 0, y: 0, z: 86 })
    tileset.modelMatrix = Cesium.Matrix4.fromTranslation(translation)
    
    // 获取变化矩阵
    function getTransformMatrix (tileset, { x, y, z }) {
      // 高度偏差,正数为向上偏,负数为向下偏,根据真实的模型位置不断进行调整
      const heightOffset = z
      // 计算tileset的绑定范围
      const boundingSphere = tileset.boundingSphere
      // 计算中心点位置
      const cartographic = Cesium.Cartographic.fromCartesian(boundingSphere.center)
      // 计算中心点位置坐标
      const surface = Cesium.Cartesian3.fromRadians(cartographic.longitude, 
    cartographic.latitude, 0)
      // 偏移后的三维坐标
      const offset = Cesium.Cartesian3.fromRadians(cartographic.longitude + x, 
    cartographic.latitude + y, heightOffset)
    
      return Cesium.Cartesian3.subtract(offset, surface, new Cesium.Cartesian3())
    }
  4. 鼠标事件交互,鼠标悬浮,在改变选中元素的状态之前,需要将它的当前状态保存下来以便下次可以恢复。

    jsx 复制代码
    // 缓存高亮状态
    const highlighted = {
      feature: undefined,
      originalColor: new Cesium.Color()
    }
    
    // 鼠标与物体交互事件
    function initMouseInteract () {
      // 事件处理器
      const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
    
      // 鼠标悬浮选中
      handler.setInputAction((event) => {
        // 将原有高亮对象恢复
        if (Cesium.defined(highlighted.feature)) {
          highlighted.feature.color = highlighted.originalColor
          highlighted.feature = undefined
        }
        // 获取选中对象
        const pickedFeature = viewer.scene.pick(event.endPosition)
    
        if (Cesium.defined(pickedFeature)) {
          // 高亮选中对象
          if (pickedFeature !== moveSelected.feature) {
            highlighted.feature = pickedFeature
            Cesium.Color.clone(pickedFeature.color, highlighted.originalColor)
            pickedFeature.color = Cesium.Color.YELLOW
          }
        }
      }, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
  5. 鼠标事件,鼠标点击,描边轮廓使用了Cesium自带的后期效果处理器,不需要自行编写着色器等操作,因此实现起来很便捷。只需要将选中的元素放到 效果的selected对象数组内就行了。

    jsx 复制代码
    // 缓存后期效果
    let edgeEffect = null
    
    function initMouseInteract(){
    	// 鼠标点击选中
      handler.setInputAction((event) => {
        
        // 获取选中对象
        const pickedFeature = viewer.scene.pick(event.position)
    
        if (!Cesium.defined(pickedFeature)) {
          return null
        } else {
         
          // 描边效果:兼容GLTF和3DTiles
          setEdgeEffect(pickedFeature.primitive || pickedFeature)
    
          // 如果拾取的要素包含属性信息,则打印出来
          if (Cesium.defined(pickedFeature.getPropertyIds)) {
            const propertyNames = pickedFeature.getPropertyIds()
            const props = propertyNames.map(key => {
              return {
                name: key,
                value: pickedFeature.getProperty(key)
              }
            })
            console.info(props)
          }
        }
      }, Cesium.ScreenSpaceEventType.LEFT_CLICK)
    }
    
    // 选中描边
    function setEdgeEffect (feature) {
      if (edgeEffect == null) {
    		// 后期效果
        const postProcessStages = viewer.scene.postProcessStages
    
        // 增加轮廓线
        const stage = Cesium.PostProcessStageLibrary.createEdgeDetectionStage()
        stage.uniforms.color = Cesium.Color.LIME //描边颜色
        stage.uniforms.length = 0.05 // 产生描边的阀值
        stage.selected = [] // 用于放置对元素
    
    		// 将描边效果放到场景后期效果中
        const silhouette = Cesium.PostProcessStageLibrary.createSilhouetteStage([stage])
        postProcessStages.add(silhouette)
    
        edgeEffect = stage
      }
    
      // 选多个元素进行描边
      const matchIndex = edgeEffect.selected.findIndex(v => v._batchId === feature._batchId)
      if (matchIndex > -1) {
         edgeEffect.selected.splice(matchIndex, 1)
       } else {
         edgeEffect.selected.push(feature)
       }
    
    }
  6. 加载gltf模型, gltf加载后需要进行一次矩阵变换modelMatrix, 加载后启动指定索引的动画进行播放。

    jsx 复制代码
    // 加载模型
    async function loadGLTF () {
       
    	let animations = null
    
    	let modelMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(
    	  Cesium.Cartesian3.fromDegrees(lng,lat,altitude)
    	)
    	
    	const model = await Cesium.Model.fromGltfAsync({
    	  url: './static/gltf/windmill.glb',
    	  modelMatrix: modelMatrix,
    	  scale: 30,
    	  // minimumPixelSize: 128, // 设定模型最小显示尺寸
    	  gltfCallback: (gltf) => {
    	    animations = gltf.animations
    	  }
    	})
    
      model.readyEvent.addEventListener(() => {
        const ani = model.activeAnimations.add({
          index: animations.length - 1, // 播放第几个动画
          loop: Cesium.ModelAnimationLoop.REPEAT, //循环播放
          multiplier: 1.0 //播放速度
        })
        ani.start.addEventListener(function (model, animation) {
          console.log(`动画开始: ${animation.name}`)
        })
      })
    
      viewer.scene.primitives.add(model)
    }

部署说明

  1. 场景演示包括前端工程、GIS数据分发服务、服务端接口几个部分
  2. 前端工程使用vue3开发,其中CesiumJs通过NPM依赖包引入
  3. 场景中相关图层均为静态文件,可放入主工程静态目录中,也可以独立部署(需解决跨域访问),或者使用cesiumlab3分发服务便于管理
  4. web端场景对终端设备和浏览器有一定要求,具体配置需要进一步测试

总结

在本文中并没有涉及到服务端数据的接入,数据接入进来后,我们可以利用Cesium在GIS开发领域强大功能,与three.js的webGL开发优势,两者相互融合创建更多数据可视化效果。那么关于Cesium和three.js的融合开发还在初步探索阶段,希望下一次有精彩内容分享给大家。

相关链接

最新版cesium集成threejs

Cesium和Three.js结合的5个方案

Cesium实现更实用的3D描边效果

相关推荐
zqx_71 小时前
随记 前端框架React的初步认识
前端·react.js·前端框架
惜.己1 小时前
javaScript基础(8个案例+代码+效果图)
开发语言·前端·javascript·vscode·css3·html5
什么鬼昵称2 小时前
Pikachu-csrf-CSRF(get)
前端·csrf
长天一色2 小时前
【ECMAScript 从入门到进阶教程】第三部分:高级主题(高级函数与范式,元编程,正则表达式,性能优化)
服务器·开发语言·前端·javascript·性能优化·ecmascript
NiNg_1_2342 小时前
npm、yarn、pnpm之间的区别
前端·npm·node.js
秋殇与星河2 小时前
CSS总结
前端·css
BigYe程普3 小时前
我开发了一个出海全栈SaaS工具,还写了一套全栈开发教程
开发语言·前端·chrome·chatgpt·reactjs·个人开发
余生H3 小时前
前端的全栈混合之路Meteor篇:关于前后端分离及与各框架的对比
前端·javascript·node.js·全栈
程序员-珍3 小时前
使用openapi生成前端请求文件报错 ‘Token “Integer“ does not exist.‘
java·前端·spring boot·后端·restful·个人开发
axihaihai3 小时前
网站开发的发展(后端路由/前后端分离/前端路由)
前端