不再需要UE美术,前端轻松解决水利开闸放水难题!!!

4,274 阅读13分钟

功能解析

在水利、水务数字孪生三维项目中,可能会遇到前端需要实现闸口开闸放水的功能需求。那我一同来看看如何实现开闸放水这个功能吧~~~

开闸放水.gif 开闸放水.gif

数据准备层面:

  • 需要将水闸与闸门进行分离,分开两个模型进行发布(便于后续闸门的位移)

数据层面_1.gif

前端实现层面:

  1. 闸门向上升起移动;
  2. 创建粒子效果(更好模拟水流冲击效果);
  3. 水流体仿真实现放水。

我这里提前准备好了对应的工程以及代码,大家可以下载下来实践一下噢

专题资源链接(包含数据、代码)下载:

pan.baidu.com/s/1nB4z72LW…

该代码为html,需要将lib/aircity下的SDK替换成本地Cloud SDK就可以直接在浏览器中进行预览啦~~~

(代码学习省流版)- 查看完整代码tab

替换SDK步骤

  • Cloud SDK获取方式:将SDK文件夹的ac.min.js进行复制;

sdk.png

  • 将代码/开闸放水/lib/aircity的ac.min.js替换成SDK的ac.min.js。

lib.png

实现步骤

我们根据上面流程进行详细拆解剖析。

闸门向上升起移动

  1. 获取闸门信息,绑定闸门对应信息。(涉及对象infoTree)

  • 图层名称与id绑定;

  • 记录闸门的初始位置,方便后续重置闸门。

  • 记录闸门的开启位置,方便后续移动闸门。

  1. 闸门进行运动动画(涉及对象TileLayer)

  • 让闸门可以从起点位移到终点,其中位移速度可控。(涉及对象tileLayer)

创建粒子效果
创建粒子效果方式有两种:explorer提前创建好通过控制图层显隐、通过代码使用对象CustomObject进行创建。 这里呢,给大家减少负担~通过控制图层显隐来创建。

  1. explorer主页-偏好设置-本地资源目录挂载pak;
  2. ‘资源库-粒子-水’选择合适粒子将其拖入至场景中;
  3. 提前在explorer将水花粒子提前创建好,以文件夹将每个闸门水花粒子进行管理,并给文件夹命名进行命名;
  4. 控制水花粒子的显示。(涉及对象infoTree)

水流体仿真实现放水

  1. 拾取出水点位置(ps:该点不要被模型遮挡);
  2. 添加水流体出水点配置信息(涉及对象fluid);
  3. 激活出水点(涉及对象fluid)

粒子需要在Explorer提前加载好:

粒子.gif

从以上步骤可以分析需要的全局参数

  • 状态参数

    • 闸门是否正在位移-moveState-Boolean:如果闸门正在位移,就禁止其他时间,防止交互阻塞
  • 效果参数

    • 动画帧数-fps-Number:唯一的动画需要多少帧完成,用于控制动画的平滑度,dts都是异步操作,直接用速度会把握不准,所以使用帧来代替速度
    • 闸门升起的目标高度-risingHeight-Number:用于表示闸门需要升起的高度。
  • 对象

    • infotreeObj:用于存储图层的必要信息
    • gateLocation:用于存储闸门位置
    • controlGate:控制打开闸门的序列
    • fluidStyle:流体样式,共28种水样式,取值范围:[0~27]
  • 对象配置

    • gate:闸门名称

    • fluidSources:出水点点位对象,用于存储出水点相关信息

      • coordinate:出水点位置,用于计算出水点范围(出水点包围盒)
      • velocity: 出水点流速、流向方位设置;
      • bbox_length: 用于设置出水点包围盒宽高范围(此值大小影响水流体速度,值越大流速越大);
      • bbox_height: 用于设置出水点包围盒高度范围(此值大小影响水流体速度,值越大流速越大);
      • shape:出水点形状,0矩形出水点,1圆形出水点;
      • duration:出水点仿真执行时间。单位:秒,即一直执行,大于0则按时间执行
    • particle:闸门对应粒子文件夹名称

具体实现

开闸放水步骤拆解
1. 前置:定义全局参数,绑定图层信息

使用飞渡api的时候,传参都是用的id,但图层的id普遍都是随机生成的,没有实际的概念。所以我们一般会用图层名称去绑定id,一是让代码的可读性更高,二是方便后续维护。需要数组,存储对应的图层名称,这里的图层名称【gate】要和Explorer工程的名称对应,并且要从低到高排列,方便后续判断。
再创建一个infotreeObj对象,用于存储名称与id的对应关系,方便接口调用。
配置项存储了相关对应的变量数据。

const infotreeObj = {},
      fps = 8// 动画帧数:控制动画的平滑度
      risingHeight = 2 // 闸门升起的目标高度

  let moveState = false // 闸门是否正在位移
  /**
   * 系统配置
   */
  const CONFIG = [
    {
      gate'闸门01',
      fluidSource: {
        coordinate: [-6952.987934570312504, -3.84681884765625],
        velocity: [-10], // 可选参数 uv流向
        bbox_length2// 可选参数 出水点包围盒宽高
        bbox_height2// 可选参数 出水点高度
        shape0// 可选参数 取值范围:[0,1],0矩形出水点,1圆形出水点
        duration: -1 // 可选参数  出水点仿真执行时间,单位:秒,默认值:-1,即一直执行,大于0则按时间执行
      },
      particle'闸门01_粒子'
    },
    {
      gate'闸门02',
      fluidSource: {
        coordinate: [-6945.470498046875, -3.8468170166015625],
        velocity: [-10]
      },
      particle'闸门02_粒子'
    },
    {
      gate'闸门03',
      fluidSource: {
        coordinate: [-6937.9300830078125, -3.8468157958984377],
        velocity: [-10]
      },
      particle'闸门03_粒子'
    },
    {
      gate'闸门04',
      fluidSource: {
        coordinate: [-6928.81292724609375, -3.8468145751953124],
        velocity: [-10]
      },
      particle'闸门04_粒子'
    }
  ]
  let fluidStyle = 6 // 水流体样式,共28种水样式

  const gateLocation = {} // 闸门位置

  const controlGate = CONFIG.map((item, index) => index) // 控制打开的闸门序列,默认全开

在场景初始化后,为了清除上个用户执行的操作,需要使用fdapi.reset()重置一下视频流。再通过fdapi.infoTree.get()获取图层树信息。获取的信息如下:

{
"infotree":[
{"iD":"5D6728F44BEAA533268D6EA04ADC368F","index":119,"parentIndex":118,"name":"L1-4","visiblity":true,"type":"EPT_Scene"},
{"iD":"70EEA6304541BFB27E6B618878888B40","index":120,"parentIndex":118,"name":"L5","visiblity":true,"type":"EPT_Scene"},{"iD":"261F27DE488D97B44A02269B1822B71D","index":121,"parentIndex":118,"name":"L6","visiblity":true,"type":"EPT_Scene"}]
}

在初始化的过程中,使用fdapi.tileLayer.get()通过循环检索闸门相关信息,包括获取当前位置以及存储位移后位置。

/**
 * 初始化场景
 */
function initScene() {
  new DigitalTwinPlayer(HostConfig.Player, {
    domId: 'player',
    apiOptions: {
      onReady: async () => {
        fdapi.reset(1 | 2 | 4)
        const { infotree } = await fdapi.infoTree.get()
        infotree.forEach(item => {
          infotreeObj[item.name] = item.iD
        })
        // 获取闸门详细信息。主要获取当前位置
        const gateIdList = CONFIG.map(item => infotreeObj[item.gate])
        const { data: gates } = await fdapi.tileLayer.get(gateIdList)

        // 存储每个图层对应的初始化位置与位移后位置
        gates.forEach((item, index) => {
          const name = CONFIG[index].gate
          if (!gateLocation[name]) gateLocation[name] = {}
          gateLocation[name].init = item.location
          gateLocation[name].open = [            item.location[0],
            item.location[1],
            item.location[2] + risingHeight
          ]
        })
      }
    }
  })
}
2. 闸门位移

在DTS中实现图层的位移需要使用fdapi.tileLayer.setLocation(),但是只能瞬间移动,不能做到平滑移动。 这里引用一个方法,通过两个点的坐标以及分段数,计算两个点之间的的点位坐标。有了这个方法我们就在两个点之间有了非常多的点位,可以通过fdapi.tileLayer.setLocation()多次移动,做到平滑位移了。如下所示:

// 传参示例
getLineSegmentPoint([[0,0,20],[100,0,20]],20);

/**
 * 线段分点
 */
function getLineSegmentPoint(lineSegment, interval) {
  try {
    if (interval && lineSegment && lineSegment.length === 2) {
      const point1 = lineSegment[0]
      const point2 = lineSegment[1]

      const a = point2[1] - point1[1]
      const b = point2[0] - point1[0]
      const c = point2[2] - point1[2]

      const o = Math.sqrt(
        Math.pow(a, 2) + Math.pow(b, 2) + Math.pow(c, 2)
      )

      const n = o / interval
      const p = []

      for (let i = 1; i <= interval; i++) {
        const x = (b / o) * (n * i) + point1[0]
        const y = (a / o) * (n * i) + point1[1]
        const z = (c / o) * (n * i) + point1[2]
        p.push([x, y, z])
      }
      return p
    } else {
      console.error('线段取点失败', lineSegment, interval)
    }
  } catch (error) {
    console.error('线段取点失败', error)
  }
}

平常用其他系统大多数都是同步逻辑,在dts中api操作都是异步的,所以这里移动不需要写定时器,直接循环调用就可以做到逐帧调用代码。

开闸_1.gif 具体代码如下:

const fps = 8, // 动画帧数:控制动画的平滑度
// 传参示例
setLocation({id:'123456',location:[100,0,20]});

/**
 * 模型位移,从模型当前位置位移到指定位置
 *  pamras arr {id:string,location:number[]}
 */
const setLocation = async arr => {
  // 设置移动状态为true
  moveState = true

  // 获取模型当前位置-通过tileLayer.get 获取模型信息
  const idList = []
  for (let index in arr) idList.push(arr[index].id)
  const { data } = await fdapi.tileLayer.get(idList)

  // 获取根据起点和终点,计算路径点
  for (let index in arr) {
    const startLocation = data[index].location
    const endLocation = JSON.parse(JSON.stringify(arr[index].location))
    arr[index].pathPoints = getLineSegmentPoint(
      [startLocation, endLocation],
      fps
    )
  }

  // 每一帧根据路径点移动一次模型
  for (let f = 0; f <= fps - 1; f++) {
    for (let index in arr)
      fdapi.tileLayer.setLocation(
        arr[index].id,
        arr[index].pathPoints[f],
        null
      )

    if (f == fps - 1) moveState = false
  }
}

从上图看到四个闸门还是有依次到达,实际想要的效果是让这几个图层,在上升的时候,是同步进行上升的。这时候我们可以用到updateBegin()updateEnd()方法。在开始修改之前调用fdapi.tileLayer.updateBegin(),然后可以多次调用fdapi.tileLayer.setLocation()方法,最后调用fdapi.tileLayer.updateEnd()提交修改更新数据。这样可以让两个方法中间的位移操作变成同步。

开闸_2.gif 最终改造后的代码如下:

 /**
   * 模型位移,从模型当前位置位移到指定位置
   *  pamras arr {id:string,location:number[]}
   */
  const setLocation = async arr => {
    // 设置移动状态为true
    moveState = true

    // 获取模型当前位置-通过tileLayer.get 获取模型信息
    const idList = []
    for (let index in arr) idList.push(arr[index].id)
    const { data } = await fdapi.tileLayer.get(idList)

    // 获取根据起点和终点,计算路径点
    for (let index in arr) {
      const startLocation = data[index].location
      const endLocation = JSON.parse(JSON.stringify(arr[index].location))
      arr[index].pathPoints = getLineSegmentPoint(
        [startLocation, endLocation],
        fps
      )
    }

    // 每一帧根据路径点移动一次模型
    for (let f = 0; f <= fps - 1; f++) {
      fdapi.tileLayer.updateBegin()
      for (let index in arr)
        fdapi.tileLayer.setLocation(
          arr[index].id,
          arr[index].pathPoints[f],
          null
        )
      await fdapi.tileLayer.updateEnd()

      if (f == fps - 1) moveState = false
    }
  }
3. 触发闸门位移

根据传入的对应的闸门的序列进行实现对应闸门序列的闸门位移,其中位移数组在初始化中已经对对应的位移数组进行储存。

async function startWaterRelease(arr = controlGate) {
  if (moveState) return

  // 构造 setLocationList id,对应结束的位置
  const setLocationList = [] // 闸门位移数组

  arr.forEach(index => {
    const item = CONFIG[index]
    // 闸门位移数组
    setLocationList.push({
      id: infotreeObj[item.gate],
      location: gateLocation[item.gate].open
    })

  })

  // 闸门开始位移
  setLocation(setLocationList)
}
4. 粒子的显示

水花粒子是在explorer提前加载的,使用fdapi.infoTree进行控制。在开闸门之后,对水花粒子使用fdapi.infoTree.show()进行显示。

代码_粒子.gif

 // 显示所有的粒子效果
await fdapi.infoTree.show(arr.map(index => infotreeObj[CONFIG[index].particle]))

将上述代码,写入到触发闸门位移方法中。如下所示:

async function startWaterRelease(arr = controlGate) {
  if (moveState) return

  // 构造 setLocationList id,对应结束的位置
  const setLocationList = [] // 闸门位移数组

  arr.forEach(index => {
    const item = CONFIG[index]
    // 闸门位移数组
    setLocationList.push({
      id: infotreeObj[item.gate],
      location: gateLocation[item.gate].open
    })

  })

  // 闸门开始位移
  setLocation(setLocationList)
  await fdapi.infoTree.show(arr.map(index => infotreeObj[CONFIG[index].particle]))
}
5. 水流体仿真

添加了水花粒子之后,需要使用fluid进行模拟流体仿真。首先我们需要在场景中进行拾取出水点位,推荐闸门前位置,且位置上面无模型遮挡item.fluidSource用于存储对应出水点相关信息,其中coordinate是出水点位置,使用出水点位置主要用于计算出水点的包围盒范围,velocity代表水流体的流速流向,duration代表水点仿真执行时间,这些参数根据实际项目进行调节。

onst fluidSources = [] // 水流体出水口
arr.forEach(index => {
  const item = CONFIG[index]

  // 构造出水点信息对象
  const [x, y, h] = item.fluidSource.coordinate
  let length = item.fluidSource.bbox_length || 2
  let height = item.fluidSource.bbox_height || 2
  fluidSources.push({
    id: `fluid_${item.gate}`,
    bbox: [x - length, y - length, h, x + length, y + length, h + height],
    velocity: item.fluidSource.velocity || [0, 0],
    shape: item.fluidSource.shape || 0,
    duration: item.fluidSource.duration || -1
  })
})

获取了对应的出水点信息,我们这里就需要进行创建对应的流体仿真对象。
在创建前我们需要获取整个出水点的包围盒,首先需要在多个出水点中计算一个中间值,使用getPointsCenter()进行计算;然后根据这个中间值来设置对应的包围盒,使用getBboxByPoints()进行获取。

/**
 * 计算水流体中点
 *  pamras points 点坐标数组
 */
function getPointsCenter(points) {
  var point_num = points.length // 坐标点个数
  if (point_num === 0) return null // 如果没有点,返回null

  // 初始化累加器
  var X = 0,
    Y = 0,
    Z = 0,
    H = 0 // X, Y, Z 用于计算经纬度中心,H 用于累加高度

  for (let i = 0; i < point_num; i++) {
    let [lat, lng, height] = points[i] // 解构数组元素,得到纬度、经度和高度
    lat = (parseFloat(lat) * Math.PI) / 180 // 将纬度转换为弧度
    lng = (parseFloat(lng) * Math.PI) / 180 // 将经度转换为弧度

    // 将经纬度转换为三维笛卡尔坐标
    var x = Math.cos(lat) * Math.cos(lng)
    var y = Math.cos(lat) * Math.sin(lng)
    var z = Math.sin(lat)

    // 累加每个点的 x, y, z 值
    X += x
    Y += y
    Z += z

    // 累加高度值(注意:这里只是简单地累加,并没有进行平均或其他处理)
    H += parseFloat(height)
  }

  // 计算经纬度的平均值
  X = X / point_num
  Y = Y / point_num
  Z = Z / point_num

  // 反转换回经纬度
  var centerLng = Math.atan2(Y, X)
  var centerLat = Math.atan2(Z, Math.sqrt(X * X + Y * Y))

  // 将弧度转换回度数
  centerLng = (centerLng * 180) / Math.PI
  centerLat = (centerLat * 180) / Math.PI

  // 计算平均高度(如果需要的话)
  var averageHeight = H / point_num

  // 返回结果:中心点的经纬度(度数)和平均高度(如果需要)
  // 注意:这里的平均高度可能并没有实际意义,因为它没有考虑地形等因素
  return [centerLat, centerLng, averageHeight]
}

/**
 * 计算水流体bbox
 *  pamras points 点坐标数组
 *  pamras length bbox长宽
 *  pamras height 垂直高度
 */
function getBboxByPoints(points, length = 500, height = 100) {
  const point = getPointsCenter(points)
  return [
    point[0] - length,
    point[1] - length,
    -height,
    point[0] + length,
    point[1] + length,
    height
  ]
}

通过上述两个方法就可以实现获取所有出水点的包围盒,结合之前的出水点信息就可以通过fdapi.fluid.add()实现水流体的添加

await fdapi.fluid.add({
  id'fluid',
  bboxgetBboxByPoints(CONFIG.map(item => item.ffiud_coordinate)),
  style: fluidStyle,
  sources: fluidSources
  })

添加完水流体之后,还需要对水流体实现激活,才可以实现水流效果,通过fdapi.fluid.continueSource()实现。

开闸放水模拟.gif

// 激活出水点
await fdapi.fluid.continueSource({
  id'fluid',
  sources: fluidSources.map(s => {
    return { id: s.id, activetrue }
  })
})
完整触发开闸放水

完整的触发开闸放水代码剖析完了,放上对应的触发开闸放水功能的代码。

开闸放水模拟.gif

/**
 * 开闸放水
 */
async function startWaterRelease(arr = controlGate) {
  if (moveState) return

  // 构造 setLocationList id,对应结束的位置
  const setLocationList = [], // 闸门位移数组
    fluidSources = [] // 水流体出水口

  arr.forEach(index => {
    const item = CONFIG[index]

    // 闸门位移数组
    setLocationList.push({
      id: infotreeObj[item.gate],
      location: gateLocation[item.gate].open
    })

    // 构造出水点信息对象
    const [x, y, h] = item.fluidSource.coordinate
    let length = item.fluidSource.bbox_length || 2
    let height = item.fluidSource.bbox_height || 2
    fluidSources.push({
      id`fluid_${item.gate}`,
      bbox: [
        x - length,
        y - length,
        h,
        x + length,
        y + length,
        h + height
      ],
      velocity: item.fluidSource.velocity || [00],
      shape: item.fluidSource.shape || 0,
      duration: item.fluidSource.duration || -1
    })
    console.log(fluidSources)
  })

  // 闸门开始位移
  setLocation(setLocationList)

  // 显示所有的粒子效果
  await fdapi.infoTree.show(
    arr.map(index => infotreeObj[CONFIG[index].particle])
  )

  // 添加水流体
  await fdapi.fluid.add({
    id'fluid',
    bboxgetBboxByPoints(
      CONFIG.map(item => item.fluidSource.coordinate)
    ),
    style: fluidStyle,
    sources: fluidSources
  })
  // 激活出水点
  await fdapi.fluid.continueSource({
    id'fluid',
    sources: fluidSources.map(s => {
      return { id: s.idactivetrue }
    })
  })
}
关闸
关闸.gif 关闸需要实现三个操作:
  1. 将闸门恢复到原始位置;
  2. 水花粒子隐藏,使用fdapi.infoTree.hide()实现水花粒子的隐藏;
  3. 流体进行停止,使用fdapi.fluid.stopSource()将水流体进行停止。
 /**
   * 关闸
   */
  async function closeGates(arr = controlGate) {
    if (moveState) return

    const setLocationList = [], // 闸门位移数组
      activateSources = [] // 暂停出水点

    arr.forEach(index => {
      const item = CONFIG[index]

      // 闸门位移数组
      setLocationList.push({
        id: infotreeObj[item.gate],
        location: gateLocation[item.gate].init
      })

      activateSources.push({
        id`fluid_${item.gate}`,
        activefalse
      })
    })
    setLocation(setLocationList)
    fdapi.fluid.stopSource({
      id'fluid',
      sources: activateSources
    })
    // 隐藏粒子效果
    fdapi.infoTree.hide(
      arr.map(index => infotreeObj[CONFIG[index].particle])
    )
  }

完整代码

该代码是完整的代码,包含开闸放水、关闸、重置。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>开闸放水</title>
    <link rel="stylesheet" href="./lib/styles.css" />
    <script src="./lib/aircity/ac_conf.js"></script>
    <script src="./lib/aircity/ac.min.js"></script>
  </head>
  <body>
    <div id="player"></div>
    <div class="btn-list">
      <div onclick="startWaterRelease()" class="btn">开闸放水</div>
      <div onclick="closeGates()" class="btn">关闸</div>
      <div onclick="resetAll()" class="btn">重置</div>
    </div>

    <script>
      const infotreeObj = {},
        fps = 8, // 动画帧数:控制动画的平滑度
        risingHeight = 2 // 闸门升起的目标高度

      let moveState = false // 闸门是否正在位移

      /**
       * 系统配置
       */
      const CONFIG = [
        {
          gate: '闸门01',
          fluidSource: {
            coordinate: [-6952.987934570312504, -3.84681884765625],
            velocity: [-10], // 可选参数 uv流向
            bbox_length: 2, // 可选参数 出水点包围盒宽高
            bbox_height: 2, // 可选参数 出水点高度
            shape: 0, // 可选参数 取值范围:[0,1],0矩形出水点,1圆形出水点
            duration: -1 // 可选参数  出水点仿真执行时间,单位:秒,默认值:-1,即一直执行,大于0则按时间执行
          },
          particle: '闸门01_粒子'
        },
        {
          gate: '闸门02',
          fluidSource: {
            coordinate: [-6945.470498046875, -3.8468170166015625],
            velocity: [-10]
          },
          particle: '闸门02_粒子'
        },
        {
          gate: '闸门03',
          fluidSource: {
            coordinate: [-6937.9300830078125, -3.8468157958984377],
            velocity: [-10]
          },
          particle: '闸门03_粒子'
        },
        {
          gate: '闸门04',
          fluidSource: {
            coordinate: [-6928.81292724609375, -3.8468145751953124],
            velocity: [-10]
          },
          particle: '闸门04_粒子'
        }
      ]
      let fluidStyle = 6 // 水流体样式,共28种水样式

      const gateLocation = {} // 闸门位置

      const controlGate = CONFIG.map((item, index) => index) // 控制打开的闸门序列,默认全开

      /**
       * 初始化场景
       */
      function initScene() {
        new DigitalTwinPlayer(HostConfig.Player, {
          domId: 'player',
          apiOptions: {
            onReady: async () => {
              fdapi.reset(1 | 2 | 4)
              const { infotree } = await fdapi.infoTree.get()
              infotree.forEach(item => {
                infotreeObj[item.name] = item.iD
              })
              // 获取闸门详细信息。主要获取当前位置
              const gateIdList = CONFIG.map(item => infotreeObj[item.gate])
              const { data: gates } = await fdapi.tileLayer.get(gateIdList)

              // 存储每个图层对应的初始化位置与位移后位置
              gates.forEach((item, index) => {
                const name = CONFIG[index].gate
                if (!gateLocation[name]) gateLocation[name] = {}
                gateLocation[name].init = item.location
                gateLocation[name].open = [
                  item.location[0],
                  item.location[1],
                  item.location[2] + risingHeight
                ]
              })
            }
          }
        })
      }

      /**
       * 线段分点
       */
      function getLineSegmentPoint(lineSegment, interval) {
        try {
          if (interval && lineSegment && lineSegment.length === 2) {
            const point1 = lineSegment[0]
            const point2 = lineSegment[1]

            const a = point2[1] - point1[1]
            const b = point2[0] - point1[0]
            const c = point2[2] - point1[2]

            const o = Math.sqrt(
              Math.pow(a, 2) + Math.pow(b, 2) + Math.pow(c, 2)
            )

            const n = o / interval
            const p = []

            for (let i = 1; i <= interval; i++) {
              const x = (b / o) * (n * i) + point1[0]
              const y = (a / o) * (n * i) + point1[1]
              const z = (c / o) * (n * i) + point1[2]
              p.push([x, y, z])
            }
            return p
          } else {
            console.error('线段取点失败', lineSegment, interval)
          }
        } catch (error) {
          console.error('线段取点失败', error)
        }
      }

      /**
       * 模型位移,从模型当前位置位移到指定位置
       *  pamras arr {id:string,location:number[]}
       */
      const setLocation = async arr => {
        // 设置移动状态为true
        moveState = true

        // 获取模型当前位置-通过tileLayer.get 获取模型信息
        const idList = []
        for (let index in arr) idList.push(arr[index].id)
        const { data } = await fdapi.tileLayer.get(idList)

        // 获取根据起点和终点,计算路径点
        for (let index in arr) {
          const startLocation = data[index].location
          const endLocation = JSON.parse(JSON.stringify(arr[index].location))
          arr[index].pathPoints = getLineSegmentPoint(
            [startLocation, endLocation],
            fps
          )
        }

        // 每一帧根据路径点移动一次模型
        for (let f = 0; f <= fps - 1; f++) {
          fdapi.tileLayer.updateBegin()
          for (let index in arr)
            fdapi.tileLayer.setLocation(
              arr[index].id,
              arr[index].pathPoints[f],
              null
            )
          await fdapi.tileLayer.updateEnd()

          if (f == fps - 1) moveState = false
        }
      }

      /**
       * 计算水流体中点
       *  pamras points 点坐标数组
       */
      function getPointsCenter(points) {
        var point_num = points.length // 坐标点个数
        if (point_num === 0) return null // 如果没有点,返回null

        // 初始化累加器
        var X = 0,
          Y = 0,
          Z = 0,
          H = 0 // X, Y, Z 用于计算经纬度中心,H 用于累加高度

        for (let i = 0; i < point_num; i++) {
          let [lat, lng, height] = points[i] // 解构数组元素,得到纬度、经度和高度
          lat = (parseFloat(lat) * Math.PI) / 180 // 将纬度转换为弧度
          lng = (parseFloat(lng) * Math.PI) / 180 // 将经度转换为弧度

          // 将经纬度转换为三维笛卡尔坐标
          var x = Math.cos(lat) * Math.cos(lng)
          var y = Math.cos(lat) * Math.sin(lng)
          var z = Math.sin(lat)

          // 累加每个点的 x, y, z 值
          X += x
          Y += y
          Z += z

          // 累加高度值(注意:这里只是简单地累加,并没有进行平均或其他处理)
          H += parseFloat(height)
        }

        // 计算经纬度的平均值
        X = X / point_num
        Y = Y / point_num
        Z = Z / point_num

        // 反转换回经纬度
        var centerLng = Math.atan2(Y, X)
        var centerLat = Math.atan2(Z, Math.sqrt(X * X + Y * Y))

        // 将弧度转换回度数
        centerLng = (centerLng * 180) / Math.PI
        centerLat = (centerLat * 180) / Math.PI

        // 计算平均高度(如果需要的话)
        var averageHeight = H / point_num

        // 返回结果:中心点的经纬度(度数)和平均高度(如果需要)
        // 注意:这里的平均高度可能并没有实际意义,因为它没有考虑地形等因素
        return [centerLat, centerLng, averageHeight]
      }

      /**
       * 计算水流体bbox
       *  pamras points 点坐标数组
       *  pamras length bbox长宽
       *  pamras height 垂直高度
       */
      function getBboxByPoints(points, length = 500, height = 100) {
        const point = getPointsCenter(points)
        return [
          point[0] - length,
          point[1] - length,
          -height,
          point[0] + length,
          point[1] + length,
          height
        ]
      }

      /**
       * 开闸放水
       */
      async function startWaterRelease(arr = controlGate) {
        if (moveState) return

        // 构造 setLocationList id,对应结束的位置
        const setLocationList = [], // 闸门位移数组
          fluidSources = [] // 水流体出水口

        arr.forEach(index => {
          const item = CONFIG[index]

          // 闸门位移数组
          setLocationList.push({
            id: infotreeObj[item.gate],
            location: gateLocation[item.gate].open
          })

          // 构造出水点信息对象
          const [x, y, h] = item.fluidSource.coordinate
          let length = item.fluidSource.bbox_length || 2
          let height = item.fluidSource.bbox_height || 2
          fluidSources.push({
            id: `fluid_${item.gate}`,
            bbox: [
              x - length,
              y - length,
              h,
              x + length,
              y + length,
              h + height
            ],
            velocity: item.fluidSource.velocity || [0, 0],
            shape: item.fluidSource.shape || 0,
            duration: item.fluidSource.duration || -1
          })
          console.log(fluidSources)
        })

        // 闸门开始位移
        setLocation(setLocationList)

        // 显示所有的粒子效果
        await fdapi.infoTree.show(
          arr.map(index => infotreeObj[CONFIG[index].particle])
        )

        // 添加水流体
        await fdapi.fluid.add({
          id: 'fluid',
          bbox: getBboxByPoints(
            CONFIG.map(item => item.fluidSource.coordinate)
          ),
          style: fluidStyle,
          sources: fluidSources
        })
        // 激活出水点
        await fdapi.fluid.continueSource({
          id: 'fluid',
          sources: fluidSources.map(s => {
            return { id: s.id, active: true }
          })
        })
      }

      /**
       * 关闸
       */
      async function closeGates(arr = controlGate) {
        if (moveState) return

        const setLocationList = [], // 闸门位移数组
          activateSources = [] // 暂停出水点

        arr.forEach(index => {
          const item = CONFIG[index]

          // 闸门位移数组
          setLocationList.push({
            id: infotreeObj[item.gate],
            location: gateLocation[item.gate].init
          })

          activateSources.push({
            id: `fluid_${item.gate}`,
            active: false
          })
        })
        setLocation(setLocationList)
        fdapi.fluid.stopSource({
          id: 'fluid',
          sources: activateSources
        })
        // 隐藏粒子效果
        fdapi.infoTree.hide(
          arr.map(index => infotreeObj[CONFIG[index].particle])
        )
      }

      /**
       * 重置所有状态
       */
      async function resetAll() {
        if (moveState) return
        await fdapi.infoTree.hide(
          CONFIG.map(item => infotreeObj[item.particle])
        )
        fdapi.fluid.delete('fluid')

        // 立即重置闸门位置
        fdapi.tileLayer.updateBegin()
        for (let index in CONFIG) {
          fdapi.tileLayer.setLocation(
            infotreeObj[CONFIG[index].gate],
            gateLocation[CONFIG[index].gate].init
          )
        }
        fdapi.tileLayer.updateEnd()
      }

      // 窗口自适应
      function onResize() {
        const player = document.getElementById('player')
        player.style.width = window.innerWidth + 'px'
        player.style.height = window.innerHeight + 'px'
      }

      // 事件监听
      window.addEventListener('load', initScene)
      window.addEventListener('resize', onResize)
    </script>
  </body>
</html>

开发小tip

在项目开发中,可以直接复用代码哟~只需要修改对应配置项,就可以轻松实现前端解决开闸放水难题啦~~~

开闸放水模拟.gif