普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月20日首页

Cesium 海量点位不卡顿!图标动态聚合效果深度解析,看完直接抄代码!

作者 李剑一
2026年3月19日 09:45

接上文# 告别冗余代码!Cesium点位图标模糊、重叠?自适应参数调优攻略,一次封装终身复用!,在地图上创建图标是基础操作,但是当地图上的图标过多的时候展示效果其实并不好。

毕竟谁也不想看到密密麻麻的图标,所以部分距离相近的图标应该聚合在一起,形成一个聚合图标展示出来。

image.png

在Cesium开发中,图标聚合能够解决海量图标重叠、界面杂乱、性能卡顿等问题。

尤其在智慧安防、智慧园区、设备监控等场景,几十个甚至上百个摄像头/设备图标挤在一块,不仅看不清,还会严重影响地图流畅度。

解决方案

通过监听相机高度,高度超过阈值,自动开启聚合。

根据计算屏幕像素距离,把三维坐标转成屏幕坐标,算两点多远,距离小于设定值,归为一组。

image.png

这时候隐藏原始图标,只显示聚合图标。

生成聚合点:显示图标+数量,拉近后自动散开。

实现代码

计算屏幕距离 + 判断是否在屏幕内。是聚合的核心基础:把三维坐标转屏幕坐标,再算距离。

/**
 * 计算两点在屏幕上的像素距离
 */
const calculateScreenDistance = (pos1, pos2) => {
    if (!viewer.value || !viewer.value.scene) return Infinity
    
    const scene = viewer.value.scene
    try {
        // 世界坐标 → 屏幕坐标
        const screenPos1 = Cesium.SceneTransforms.worldToWindowCoordinates(scene, pos1)
        const screenPos2 = Cesium.SceneTransforms.worldToWindowCoordinates(scene, pos2)
        
        if (!screenPos1 || !screenPos2) return Infinity
        
        // 勾股定理算像素距离
        const dx = screenPos1.x - screenPos2.x
        const dy = screenPos1.y - screenPos2.y
        return Math.sqrt(dx * dx + dy * dy)
    } catch (error) {
        return Infinity
    }
}

/**
 * 检查点是否在屏幕上可见
 */
const isPositionOnScreen = (position) => {
    if (!viewer.value || !viewer.value.scene) return false
    try {
        const screenPos = Cesium.SceneTransforms.worldToWindowCoordinates(viewer.value.scene, position)
        return screenPos != null
    } catch (error) {
        return false
    }
}

生成聚合点,图标更大、创建label显示当前标签数量更明显。

/**
 * 创建聚合图标
 */
const createClusterIcon = (clusterData) => {
    if (!viewer.value) return null
    const { icons, type, center } = clusterData
    const count = icons.length

    // 坐标转换
    const cartographic = Cesium.Cartographic.fromCartesian(center)
    const longitude = Cesium.Math.toDegrees(cartographic.longitude)
    const latitude = Cesium.Math.toDegrees(cartographic.latitude)

    // 创建聚合实体
    const clusterId = `cluster_${type}_${Date.now()}`
    const entity = viewer.value.entities.add({
        id: clusterId,
        position: center,
        billboard: {
            image: getClusterIconUrl(type),
            scale: 1.2,
            width: 40,
            height: 40,
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
            disableDepthTestDistance: Number.POSITIVE_INFINITY
        }
    })

    // 聚合数量标签
    const typeName = getTypeDisplayName(type)
    entity.label = {
        text: `${typeName} ${count}个`,
        font: '14px sans-serif',
        fillColor: Cesium.Color.WHITE,
        outlineColor: Cesium.Color.BLACK,
        outlineWidth: 2,
        pixelOffset: new Cesium.Cartesian2(0, -50),
        showBackground: true,
        disableDepthTestDistance: Number.POSITIVE_INFINITY
    }

    // 存入聚合列表
    clusterEntities.set(clusterId, { entity, icons, type, center })
    return entity
}

动态计算聚合阈值,通过遍历图标 → 分组 → 合并/显示,自动隐藏原始图标,显示聚合点。

/**
 * 更新图标聚合状态
 */
const updateClustering = () => {
    if (!viewer.value || iconEntities.size === 0) return
    clearClusters()

    // 关闭聚合 = 显示全部
    if (!isClusteringEnabled.value) {
        showAllIcons()
        return
    }

    // 动态阈值:相机越高,聚合越明显
    const cameraHeight = viewer.value.camera.positionCartographic.height
    const dynamicClusterDistance = Math.min(
        MAX_SCREEN_CLUSTER_DISTANCE,
        SCREEN_CLUSTER_DISTANCE + (cameraHeight - CLUSTER_THRESHOLD) / 50
    )

    // 收集所有图标
    const allIcons = []
    iconEntities.forEach((iconData, id) => {
        const position = iconData.entity.position.getValue(Cesium.JulianDate.now())
        allIcons.push({ id, entity: iconData.entity, position, type: iconData.type })
    })

    // 先隐藏所有图标
    allIcons.forEach(icon => icon.entity.show = false)

    // 聚类算法
    const clusters = []
    const visited = new Set()

    for (let i = 0; i < allIcons.length; i++) {
        if (visited.has(i)) continue
        const current = allIcons[i]
        if (!isPositionOnScreen(current.position)) continue

        const cluster = [current]
        visited.add(i)

        // 寻找附近图标
        for (let j = i + 1; j < allIcons.length; j++) {
            if (visited.has(j)) continue
            const other = allIcons[j]
            if (!isPositionOnScreen(other.position)) continue

            const dist = calculateScreenDistance(current.position, other.position)
            if (dist <= dynamicClusterDistance) {
                cluster.push(other)
                visited.add(j)
            }
        }
        clusters.push(cluster)
    }

    // 生成聚合点 / 显示单个图标
    clusters.forEach(cluster => {
        if (cluster.length === 1) {
            cluster[0].entity.show = true
        } else {
            // 计算中心点
            let centerX = 0, centerY = 0, centerZ = 0
            cluster.forEach(icon => {
                centerX += icon.position.x
                centerY += icon.position.y
                centerZ += icon.position.z
            })
            const center = new Cesium.Cartesian3(
                centerX / cluster.length,
                centerY / cluster.length,
                centerZ / cluster.length
            )

            createClusterIcon({
                icons: cluster.map(c => c.id),
                type: 'camera',
                center
            })
        }
    })
}

总结

Cesium 图标聚合原理上很简单:

算距离 → 分组 → 隐藏/显示 → 生成聚合点

在园区级别的模型上其实启不启用影响不大,但是在城市级别,或者是多地区复杂情况的模型上还是有必要的。

能够极大的提升加载的流畅度,减少操作的卡顿。

昨天以前首页

数字孪生大屏必看:Cesium 3D 模型选中交互,3 种高亮效果拿来就用!

作者 李剑一
2026年3月15日 16:23

接前文,3D模型加载到页面以后肯定要执行各种各样的操作,模型在大屏上的主要作用是执行相应的建筑物交互。

问题

这里需要注意3D模型的加载仍然存在一些问题。

首先是如果你使用的GLTF文件,在某些特殊情况下可能导致模型的网格加载异常,会出现无法选中的情况。

这一点我目前没发现有什么太好的解决方案,所以这里采用GLB文件规避掉了这个问题。

image.png

如果你手里只有GLTF文件,可以考虑使用 Blender 进行一下转换。

另外模型本身离地高度都是 0 的话可能存在无法选中的问题,所以这里建议背景设置离地高度为 -0.5,普通模型正常为 0

还有就是模型选中以后的显示问题。

解决方案

模型无法选中和选择错误的问题通过两个方案进行规避,一个是height离地高度,一个是pickable拾取

首先设置背景的离地高度为 -0.5,普通可以选中的模型离地高度为 0

image.png

另外设置一下 pickPriority拾取优先级pickable是否可拾取

最后就是设置模型的选中效果,这里我简单写了三种效果给大家选择,可以自行决定。

实际代码

模型添加的时候增加拾取优先级参数:

const buildingEntity = viewer.entities.add({
    id: options.id, // 唯一ID,点击交互时识别核心
    name: options.properties.name || options.id, // 建筑名称(可选)
    position: position,
    orientation: orientation, // 控制模型朝向
    pickPriority: options.pickPriority, // (核心)添加拾取优先级
    pickable: options.pickable,  // (核心)允许拾取
    model: {
        uri: options.modelUrl, // glTF/glb 模型路径
        scale: options.scale || 1.0, // 保证模型真实比例(建模时单位为米)
        minimumPixelSize: 0, // 取消最小像素限制,模型随地图缩放正常变化
        maximumScale: 20000, // 最大缩放限制
        runAnimations: false, // 静态建筑关闭动画(节省性能)
        clampToGround: true, // 贴地(自动适配地形高度,可选)
    },
    properties: options.properties || {}, // 绑定自定义属性(如状态接口)
});

这里的参数其实在模型选择那里可以再增加一层判断。

模型选中方法主要有三种:

// 1. 轮廓线方案
/**
 * 选中指定模型
 * @param {Cesium.Entity} entity 要选中的模型实体
 * @param {Function} onSelect 选中回调(如展示状态弹窗)
 * @param {Function} onUnselect 取消选中回调(用于先取消之前的选中)
 */
const selectModel = (entity, onSelect, onUnselect) => {
    // 取消之前的选中状态(包括回调执行)
    if (selectedEntity) {
        unselectModel(onUnselect);
    }

    // 校验是否为模型实体
    if (!entity || !entity.model) {
        console.warn('❌ 选中的不是模型实体');
        return;
    }

    // 标记为当前选中实体
    selectedEntity = entity;

    // 轮廓线高亮(更醒目,性能略高,需 Cesium 1.90+)
    entity.model.outlineColor = Cesium.Color.RED;
    entity.model.outlineWidth = 2;
    entity.model.outline = true;

    // 执行选中回调(绑定业务逻辑)
    if (typeof onSelect === 'function') {
        onSelect(entity);
    }
};

如果没有特殊要求,轮廓线方案其实非常简单实用。

// 2. 颜色高亮,修改模型材质
originalModelMaterial = entity.model.color || Cesium.Color.WHITE.clone();
entity.model.color = Cesium.Color.fromCssColorString('#fb0528').withAlpha(0.8);
// 强制刷新
viewer.scene.requestRender();

这里需要注意,修改模型材质一定要执行强制刷新

// 3. 模型遮罩效果
viewer.entities.add({
    position: entity.position,
    orientation: entity.orientation,
    model: {
        uri: entity.model.uri, // 复用同一个模型文件
        scale: 0.35, // 稍微大一点
        color: Cesium.Color.fromCssColorString('#409EFF').withAlpha(0.5), // 半透明蓝
        silhouetteColor: Cesium.Color.BLUE, // 可选:配合轮廓
        silhouetteSize: 2.0
    }
});

这种效果也非常不错,复用模型文件稍大一号,让他完美的遮住原始模型,给出一个透明色作为材质,显得很有科技感。

总结

模型设计的时候推荐大家优先使用 GLB 格式替代 GLTF 规避网格加载异常问题,

另外通过pickPriority(拾取优先级)和pickable(是否可拾取)参数,从逻辑层面控制模型的交互规则,彻底解决 点错模型、点不到模型 的问题。

后续增加相关图标的单击和操作,实现小型设备的交互。

❌
❌