Cesium广告牌之自定义封装label

先给大家上预览:



这是我改善了两版之后的结果。

最开始曾尝试在原版label上改属性、以及修改材质,两种方式都出现了不同程度的问题,最终我尝试使用div来创建label,并时刻计算对应的屏幕坐标到广告牌上方,完成了一版不错的效果。

下面给出封装方法。

vue3+ts:

1. useDroneNestHtmlLabels.ts

这是核心逻辑文件,负责做两件事:

  • 把 Cesium 世界坐标转换成屏幕坐标
  • 输出模板可直接渲染的 labelOverlays
    对外只需要关心两个方法:
  • start(viewer, getSources):开始监听 Cesium postRender,持续刷新标签位置
  • stop():停止监听并清空标签
javascript 复制代码
import * as Cesium from 'cesium'
import { ref } from 'vue'

export interface DroneNestHtmlLabelOverlay {
  id: string
  text: string
  left: number
  top: number
  visible: boolean
}

export interface DroneNestHtmlLabelSource {
  id: string
  text: string
  anchorCartesian: Cesium.Cartesian3
  billboardHeight: number
  scaleByDistance?: Cesium.NearFarScalar
}

interface UseDroneNestHtmlLabelsOptions {
  labelGapPx?: number
  viewportPaddingPx?: number
}

export function useDroneNestHtmlLabels(options: UseDroneNestHtmlLabelsOptions = {}) {
  const {
    labelGapPx = 8,
    viewportPaddingPx = 40,
  } = options

  const labelOverlays = ref<DroneNestHtmlLabelOverlay[]>([])
  let removeRenderListener: (() => void) | null = null

  const getNearFarScaleValue = (scalar: Cesium.NearFarScalar | undefined, distance: number) => {
    if (!scalar) return 1
    if (distance <= scalar.near) return scalar.nearValue
    if (distance >= scalar.far) return scalar.farValue

    const ratio = (distance - scalar.near) / (scalar.far - scalar.near)
    return scalar.nearValue + (scalar.farValue - scalar.nearValue) * ratio
  }

  const buildOverlay = (
    viewer: Cesium.Viewer,
    occluder: { isPointVisible: (position: Cesium.Cartesian3) => boolean },
    source: DroneNestHtmlLabelSource,
  ): DroneNestHtmlLabelOverlay => {
    const canvas = viewer.scene.canvas
    if (!source.anchorCartesian || !occluder.isPointVisible(source.anchorCartesian)) {
      return {
        id: source.id,
        text: source.text,
        left: 0,
        top: 0,
        visible: false,
      }
    }

    const windowPosition = Cesium.SceneTransforms.wgs84ToWindowCoordinates(
      viewer.scene,
      source.anchorCartesian,
    )
    if (!windowPosition || !Number.isFinite(windowPosition.x) || !Number.isFinite(windowPosition.y)) {
      return {
        id: source.id,
        text: source.text,
        left: 0,
        top: 0,
        visible: false,
      }
    }

    const distance = Cesium.Cartesian3.distance(viewer.camera.positionWC, source.anchorCartesian)
    const scale = getNearFarScaleValue(source.scaleByDistance, distance)
    const labelOffsetPx = source.billboardHeight * scale + labelGapPx
    const isInViewport = windowPosition.x >= -viewportPaddingPx
      && windowPosition.x <= canvas.clientWidth + viewportPaddingPx
      && windowPosition.y >= -viewportPaddingPx
      && windowPosition.y <= canvas.clientHeight + viewportPaddingPx

    return {
      id: source.id,
      text: source.text,
      left: windowPosition.x,
      top: windowPosition.y - labelOffsetPx,
      visible: isInViewport,
    }
  }

  const sync = (viewer: Cesium.Viewer, sources: DroneNestHtmlLabelSource[]) => {
    const occluder = new (Cesium as any).EllipsoidalOccluder(
      viewer.scene.globe.ellipsoid,
      viewer.camera.positionWC,
    )

    labelOverlays.value = sources.map((source) => buildOverlay(viewer, occluder, source))
  }

  const start = (viewer: Cesium.Viewer, getSources: () => DroneNestHtmlLabelSource[]) => {
    removeRenderListener?.()

    const render = () => {
      sync(viewer, getSources())
    }

    viewer.scene.postRender.addEventListener(render)
    removeRenderListener = () => {
      viewer.scene.postRender.removeEventListener(render)
      removeRenderListener = null
    }
    render()
  }

  const stop = () => {
    removeRenderListener?.()
    labelOverlays.value = []
  }

  return {
    labelOverlays,
    start,
    stop,
  }
}
2. DroneNestHtmlLabelOverlay.vue

这是纯展示组件,只负责把 labelOverlays 渲染成 HTML div 标签。

javascript 复制代码
<script lang="ts">
export default {
  name: 'DroneNestHtmlLabelOverlay',
}
</script>

<script setup lang="ts">
import type { DroneNestHtmlLabelOverlay } from './useDroneNestHtmlLabels'

defineProps<{
  overlays: DroneNestHtmlLabelOverlay[]
}>()
</script>

<template>
  <div class="drone-nest-html-label-overlay">
    <div
      v-for="item in overlays"
      v-show="item.visible"
      :key="item.id"
      class="drone-nest-html-label-overlay__item"
      :style="{
        left: `${item.left}px`,
        top: `${item.top}px`,
      }"
    >
      {{ item.text }}
    </div>
  </div>
</template>

<style scoped lang="scss">
.drone-nest-html-label-overlay {
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
}

.drone-nest-html-label-overlay__item {
  position: absolute;
  padding: 6px 10px;
  border-radius: 8px;
  background: rgba(0, 0, 0, 0.72);
  color: #fff;
  font-size: 14px;
  font-weight: 600;
  line-height: 1;
  white-space: nowrap;
  transform: translate(-50%, -100%);
  text-shadow:
    0 1px 1px rgba(0, 0, 0, 0.8),
    0 0 2px rgba(0, 0, 0, 0.9);
}
</style>
3. DroneNestHtmlLabelDemo.vue

这是最小调用示例,保留的是可以直接抄到业务页里的完整链路:

  • 初始化 Cesium
  • 批量采样地形高度
  • 添加机巢广告牌
  • 组装 label source
  • 启动 HTML label 跟随
javascript 复制代码
<script lang="ts">
export default {
  name: 'DroneNestHtmlLabelDemo',
}
</script>

<script setup lang="ts">
import * as Cesium from 'cesium'
import { onBeforeUnmount, onMounted } from 'vue'

import cesiumUtils from '../../utils/cesium/cesiumUtils'
import TestDroneNestHtmlLabelOverlay from './TestDroneNestHtmlLabelOverlay.vue'
import {
  useDroneNestHtmlLabels,
  type DroneNestHtmlLabelSource,
} from './useDroneNestHtmlLabels'

type DroneNestStatus = 'idle' | 'work'

interface DroneNestDemoItem {
  id: string
  name: string
  lng: number
  lat: number
  sn: string
  status: DroneNestStatus
}

const CONTAINER_ID = 'test-drone-nest-html-label-map'
const DEFAULT_CAMERA_ALTITUDE = 42000
const DRONE_NEST_POSITION_HEIGHT = 36
const DRONE_NEST_BILLBOARD_SIZE = 30
const DRONE_NEST_SCALE_BY_DISTANCE = new Cesium.NearFarScalar(2000, 1, 120000, 0.48)

const droneNestList: DroneNestDemoItem[] = [
  {
    id: 'demo-drone-nest-01',
    name: '机场-01',
    lng: 109.94873764813244,
    lat: 39.78098592227097,
    sn: 'dx-01',
    status: 'idle',
  },
  {
    id: 'demo-drone-nest-02',
    name: '机场-02',
    lng: 109.992,
    lat: 39.811,
    sn: 'dx-02',
    status: 'work',
  },
]

let droneNestDataSource: Cesium.CustomDataSource | null = null
const droneNestLabelSources = new Map<string, DroneNestHtmlLabelSource>()
const { labelOverlays, start, stop } = useDroneNestHtmlLabels()

async function addDroneNestBatch() {
  const viewer = cesiumUtils.getViewer()
  if (!viewer) return

  droneNestDataSource = new Cesium.CustomDataSource('test-drone-nest-html-label-demo')
  viewer.dataSources.add(droneNestDataSource)
  droneNestLabelSources.clear()

  for (const item of droneNestList) {
    const terrainHeight = await cesiumUtils.getTerrainHeight(item.lng, item.lat)
    const anchorCartesian = Cesium.Cartesian3.fromDegrees(
      item.lng,
      item.lat,
      terrainHeight + DRONE_NEST_POSITION_HEIGHT,
    )

    cesiumUtils.addDroneNestOverlay({
      entities: droneNestDataSource.entities,
      idPrefix: item.id,
      lng: item.lng,
      lat: item.lat,
      sn: item.sn,
      status: () => item.status,
      anchorCartesian,
      positionHeight: DRONE_NEST_POSITION_HEIGHT,
      billboardWidth: DRONE_NEST_BILLBOARD_SIZE,
      billboardHeight: DRONE_NEST_BILLBOARD_SIZE,
    })

    droneNestLabelSources.set(item.id, {
      id: item.id,
      text: item.name,
      anchorCartesian,
      billboardHeight: DRONE_NEST_BILLBOARD_SIZE,
      scaleByDistance: DRONE_NEST_SCALE_BY_DISTANCE,
    })
  }

  start(viewer, () => Array.from(droneNestLabelSources.values()))
}

function clearDroneNestBatch() {
  const viewer = cesiumUtils.getViewer()
  stop()
  droneNestLabelSources.clear()
  if (viewer && droneNestDataSource) {
    viewer.dataSources.remove(droneNestDataSource, true)
  }
  droneNestDataSource = null
}

onMounted(() => {
  clearDroneNestBatch()
  cesiumUtils.destroy()

  const viewer = cesiumUtils.initViewer(CONTAINER_ID, {
    terrain: true,
    dayNightEffect: true,
  })

  void addDroneNestBatch()

  viewer?.camera.flyTo({
    destination: Cesium.Cartesian3.fromDegrees(
      droneNestList[0].lng,
      droneNestList[0].lat,
      DEFAULT_CAMERA_ALTITUDE,
    ),
    orientation: {
      heading: Cesium.Math.toRadians(0),
      pitch: Cesium.Math.toRadians(-55),
      roll: 0,
    },
    duration: 1.6,
  })
})

onBeforeUnmount(() => {
  clearDroneNestBatch()
  cesiumUtils.destroy()
})
</script>

<template>
  <div class="test-drone-nest-html-label-demo">
    <div :id="CONTAINER_ID" class="test-drone-nest-html-label-demo__map" />

    <TestDroneNestHtmlLabelOverlay :overlays="labelOverlays" />
  </div>
</template>

<style scoped lang="scss">
.test-drone-nest-html-label-demo {
  position: relative;
  width: 100%;
  height: 100vh;
  overflow: hidden;
  background: #020814;
}

.test-drone-nest-html-label-demo__map {
  position: absolute;
  inset: 0;
}

.test-drone-nest-html-label-demo__map:deep(.cesium-viewer),
.test-drone-nest-html-label-demo__map:deep(.cesium-viewer-cesiumWidgetContainer),
.test-drone-nest-html-label-demo__map:deep(.cesium-widget),
.test-drone-nest-html-label-demo__map:deep(canvas) {
  width: 100%;
  height: 100%;
}
</style>

如果你觉得上面太长,我还提供了一份最小可抄代码,你可以直接看懂我demo的骨架,从而在自己的项目上修改:

ts 复制代码
const { labelOverlays, start, stop } = useDroneNestHtmlLabels()
const labelSources = new Map()

for (const item of droneNestList) {
  const terrainHeight = await cesiumUtils.getTerrainHeight(item.lng, item.lat)
  const anchorCartesian = Cesium.Cartesian3.fromDegrees(
    item.lng,
    item.lat,
    terrainHeight + 36,
  )

  cesiumUtils.addDroneNestOverlay({
    entities: dataSource.entities,
    idPrefix: item.id,
    lng: item.lng,
    lat: item.lat,
    sn: item.sn,
    status: () => item.status,
    anchorCartesian,
    positionHeight: 36,
    billboardWidth: 30,
    billboardHeight: 30,
  })

  labelSources.set(item.id, {
    id: item.id,
    text: item.name,
    anchorCartesian,
    billboardHeight: 30,
    scaleByDistance: new Cesium.NearFarScalar(2000, 1, 120000, 0.48),
  })
}

start(viewer, () => Array.from(labelSources.values()))

下面是v2的封装。

vue2+js:

javascript 复制代码
<template>
    <div id="init-viewer-wrapper">
        <div
            v-for="item in labelOverlays"
            v-show="item.visible"
            :key="item.id"
            class="drone-label"
            :style="{
                left: item.left + 'px',
                top: item.top + 'px'
            }"
        >
            {{ item.text }}
        </div>
    </div>
</template>

<script>
import cesiumUtils from '@/utils/cesium/cesiumUtils.js'

export default {
    name: 'depthDetection',
    components: {},
    data() {
        return {
            points: [],
            droneNestDataSource: null,
            labelOverlays: [],
            droneNestLabelSources: [],
            removeDroneNestLabelRenderListener: null,
            droneNestList: [
                {
                    id: 'demo-drone-nest-01',
                    name: '机场-01',
                    lng: 109.94873764813244,
                    lat: 39.78098592227097
                },
                {
                    id: 'demo-drone-nest-02',
                    name: '机场-02',
                    lng: 109.992,
                    lat: 39.811
                }
            ]
        }
    },
    mounted() {
        this.initViewer()
    },
    beforeDestroy() {
        this.clearDroneNestBatch()
        cesiumUtils.destroy();
    },
    methods: {
        async initViewer() {
            this.clearDroneNestBatch()
            cesiumUtils.destroy()

            const viewer = cesiumUtils.initViewer("init-viewer-wrapper", { terrain: true });
            await this.addDroneNestBatch()

            viewer.camera.flyTo({
                destination: Cesium.Cartesian3.fromDegrees(this.droneNestList[0].lng, this.droneNestList[0].lat, 42000),
                orientation: {
                    heading: Cesium.Math.toRadians(0),
                    pitch: Cesium.Math.toRadians(-55),
                    roll: 0
                },
                duration: 1.6
            })
        },
        async addDroneNestBatch() {
            const viewer = cesiumUtils.getViewer()
            if (!viewer) return

            this.clearDroneNestBatch()
            this.droneNestDataSource = new Cesium.CustomDataSource('drone-nest-html-label-test-demo')
            viewer.dataSources.add(this.droneNestDataSource)
            this.droneNestLabelSources = []

            for (let i = 0; i < this.droneNestList.length; i += 1) {
                const item = this.droneNestList[i]
                const terrainHeight = await cesiumUtils.getTerrainHeight(item.lng, item.lat)
                const anchorCartesian = Cesium.Cartesian3.fromDegrees(item.lng, item.lat, terrainHeight + 36)

                this.droneNestDataSource.entities.add({
                    id: item.id + '-billboard',
                    position: anchorCartesian,
                    billboard: {
                        image: require('../../assets/images/droneNest_idle.png'),
                        width: 30,
                        height: 30,
                        verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
                        scaleByDistance: new Cesium.NearFarScalar(2000, 1, 120000, 0.48)
                    }
                })

                this.droneNestLabelSources.push({
                    id: item.id,
                    text: item.name,
                    anchorCartesian: anchorCartesian,
                    billboardHeight: 30,
                    scaleByDistance: new Cesium.NearFarScalar(2000, 1, 120000, 0.48)
                })
            }

            this.startDroneNestLabelSync()
        },
        clearDroneNestBatch() {
            const viewer = cesiumUtils.getViewer()
            this.stopDroneNestLabelSync()
            this.droneNestLabelSources = []
            this.labelOverlays = []

            if (viewer && this.droneNestDataSource) {
                viewer.dataSources.remove(this.droneNestDataSource, true)
            }

            this.droneNestDataSource = null
        },
        startDroneNestLabelSync() {
            const viewer = cesiumUtils.getViewer()
            if (!viewer) return

            this.stopDroneNestLabelSync()
            const render = () => {
                this.syncDroneNestLabels()
            }
            viewer.scene.postRender.addEventListener(render)
            this.removeDroneNestLabelRenderListener = () => {
                viewer.scene.postRender.removeEventListener(render)
                this.removeDroneNestLabelRenderListener = null
            }
            this.syncDroneNestLabels()
        },
        stopDroneNestLabelSync() {
            if (this.removeDroneNestLabelRenderListener) {
                this.removeDroneNestLabelRenderListener()
            }
        },
        getNearFarScaleValue(scalar, distance) {
            if (!scalar) return 1
            if (distance <= scalar.near) return scalar.nearValue
            if (distance >= scalar.far) return scalar.farValue
            const ratio = (distance - scalar.near) / (scalar.far - scalar.near)
            return scalar.nearValue + (scalar.farValue - scalar.nearValue) * ratio
        },
        getLabelOffsetPx(source, viewer) {
            const distance = Cesium.Cartesian3.distance(viewer.camera.positionWC, source.anchorCartesian)
            const scale = this.getNearFarScaleValue(source.scaleByDistance, distance)
            return source.billboardHeight * scale + 8
        },
        syncDroneNestLabels() {
            const viewer = cesiumUtils.getViewer()
            if (!viewer) {
                this.labelOverlays = []
                return
            }

            const canvas = viewer.scene.canvas
            const occluder = new Cesium.EllipsoidalOccluder(viewer.scene.globe.ellipsoid, viewer.camera.positionWC)

            this.labelOverlays = this.droneNestLabelSources.map((source) => {
                if (!source.anchorCartesian || !occluder.isPointVisible(source.anchorCartesian)) {
                    return { id: source.id, text: source.text, left: 0, top: 0, visible: false }
                }

                const windowPosition = Cesium.SceneTransforms.wgs84ToWindowCoordinates(viewer.scene, source.anchorCartesian)
                if (!windowPosition || !isFinite(windowPosition.x) || !isFinite(windowPosition.y)) {
                    return { id: source.id, text: source.text, left: 0, top: 0, visible: false }
                }

                const labelOffsetPx = this.getLabelOffsetPx(source, viewer)
                const isInViewport =
                    windowPosition.x >= -40 &&
                    windowPosition.x <= canvas.clientWidth + 40 &&
                    windowPosition.y >= -40 &&
                    windowPosition.y <= canvas.clientHeight + 40

                return {
                    id: source.id,
                    text: source.text,
                    left: windowPosition.x,
                    top: windowPosition.y - labelOffsetPx,
                    visible: isInViewport
                }
            })
        }
    }
}
</script>

<style scoped>
#init-viewer-wrapper {
    width: 100%;
    height: 100%;
    position: relative;
}

.drone-label {
    position: absolute;
    z-index: 2;
    pointer-events: none;
    padding: 6px 10px;
    border-radius: 8px;
    background: rgba(0, 0, 0, 0.72);
    color: #ffffff;
    font-size: 14px;
    font-weight: 600;
    line-height: 1;
    white-space: nowrap;
    transform: translate(-50%, -100%);
    text-shadow: 0 1px 1px rgba(0, 0, 0, 0.8), 0 0 2px rgba(0, 0, 0, 0.9);
}
</style>

怎么样,很棒吧?

相关推荐
郝学胜-神的一滴15 天前
[简化版 GAMES 101] 计算机图形学 04:二维变换上
c++·算法·unity·godot·图形渲染·unreal engine·cesium
duansamve18 天前
Cesium快速入门到精通系列教程二十五:以较长经纬度跨度为基准,将多边形充满屏幕,返回此时的中心点坐标及相机高度
cesium
阿琳a_24 天前
在github上部署个人的vitepress文档网站
前端·vue.js·github·网站搭建·cesium
云上飞4763696225 天前
glb模型在Cesium中发黑的机理分析
cesium·glb模型发黑
ct9781 个月前
Cesium的Primitive API
gis·webgl·cesium
Irene19911 个月前
OpenLayers 和 Cesium 都是流行的开源 JavaScript 库,用于在网页上构建地图和地理空间应用
openlayers·cesium
fxshy1 个月前
前端直连模型 vs 完整 MCP:大模型驱动地图的原理与实践(技术栈Vue + Cesium + Node.js + WebSocket + MCP)
前端·vue.js·node.js·cesium·mcp
棋鬼王1 个月前
Cesium(十) 动态修改白模颜色、白模渐变色、白模光圈特效、白模动态扫描光效、白模着色器
前端·javascript·vue.js·智慧城市·数字孪生·cesium
duansamve1 个月前
Cesium快速入门到精通系列教程二十四:限制相机在特定的Level之间展示地图
cesium