Cesium应用(一):解决 Cesium 海量船舶轨迹点渲染难题:轨迹数据池方案实践

引言:在船舶轨迹可视化项目中,我们经常面临一个棘手的问题:单条船舶的轨迹数据在长时间跨度下可能包含上百万个特征点,直接全部渲染会导致 Cesium 客户端严重卡顿,甚至崩溃。本文将分享一种基于 "轨迹数据池" 的高效渲染方案,通过按需加载和资源复用,轻松应对海量轨迹点的可视化挑战。

一、问题背景:为什么需要轨迹数据池?

船舶轨迹数据有两个显著特点:

  • 数据量大:单条船舶的轨迹点可能超过百万,直接全量渲染会耗尽浏览器资源
  • 视角相关性:用户通常只关心当前视口范围内的轨迹特征(如内河拐弯处、远洋岛屿附近的关键点)

传统使用Entity渲染的方式存在明显缺陷:频繁创建 / 销毁实体导致内存波动大,全量渲染时帧率骤降。因此我们需要一种更智能的渲染策略 ------根据当前视角动态渲染可见范围内的轨迹点

二、核心思路:视口可见性 + 数据池复用

1. 按需渲染:只画 "该画的点"

  • 监听相机视角变化,实时获取当前视口范围(经纬度边界)和地图层级
  • 仅渲染视口范围内的轨迹点,超出范围的点不渲染
  • 额外设置 "缓冲区":对视口范围外一定距离的点也进行渲染,避免视角快速移动时出现 "空白闪烁"

2. 数据池机制:避免频繁创建销毁

  • 预先创建一批固定数量的Primitive(底层渲染单元)作为 "数据池"
  • 视角变化时,只需更新数据池中 Primitive 的位置和状态(显示 / 隐藏),而非重新创建
  • 当需要渲染的点超过池容量时,自动扩容数据池

三、技术实现:从监听视角到数据池管理

1. 视角监听与范围计算

首先需要封装一个 Cesium 事件管理类,实时获取相机状态:

js 复制代码
import * as cesium from "cesium";
import { useCesiumEventStoreHook } from "@/stores/modules/cesiumEvent.store";
/**
 * 定义事件类型常量
 */
const EVENT_TYPES = {
  LEFT_CLICK: cesium.ScreenSpaceEventType.LEFT_CLICK,
  MOUSE_MOVE: cesium.ScreenSpaceEventType.MOUSE_MOVE,
  RIGHT_CLICK: cesium.ScreenSpaceEventType.RIGHT_CLICK,
  CAMERA_CHANGED: "changed",
};

/**
 * 定义事件处理函数的映射关系(事件类型 -> 处理函数名)
 */
const EVENT_HANDLER_MAP = {
  [EVENT_TYPES.LEFT_CLICK]: "leftClick",
  [EVENT_TYPES.MOUSE_MOVE]: "mouseMove",
  [EVENT_TYPES.RIGHT_CLICK]: "rightClick",
};

/**
 * 定义 Store 方法的映射关系(事件类型 -> Store 方法)
 */
const STORE_ACTION_MAP = {
  [EVENT_TYPES.LEFT_CLICK]: (pos) => useCesiumEventStoreHook().changeLeftClickPosition(pos),
  [EVENT_TYPES.MOUSE_MOVE]: (pos) => useCesiumEventStoreHook().changeMouseMovePosition(pos),
  [EVENT_TYPES.RIGHT_CLICK]: (pos) => useCesiumEventStoreHook().changeRightClickPosition(pos),
  [EVENT_TYPES.CAMERA_CHANGED]: (bounds) => useCesiumEventStoreHook().changeCameraBounds(bounds),
};

/**
 * 定义位置数据结构(JSDoc 说明类型)
 * @typedef {Object} CesiumPosition
 * @property {number} longitude - 经度(度)
 * @property {number} latitude - 纬度(度)
 * @property {number} height - 高度(千米)
 */

/**
 * Cesium 事件管理类
 */
export class CesiumEvent {
  /**
   * 构造函数
   * @param {cesium.Viewer} viewer - Cesium 视图实例
   */
  constructor(viewer) {
    // 统一事件声明
    this.handler = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
    this.viewer = viewer;
    this.position = null; // 存储当前位置信息(CesiumPosition 类型)
    this.bounds = null;
    this.initEvent();
  }

  /**
   * 初始化事件绑定
   */
  initEvent() {
    Object.entries(EVENT_HANDLER_MAP).forEach(([eventType, handlerName]) => {
      const handler = this[handlerName]; // 获取对应的处理函数
      if (typeof handler === "function") {
        // 绑定事件,注意保持 this 上下文
        this.handler.setInputAction((movement) => handler.call(this, movement), eventType);
      }
    });

    this.viewer.camera[EVENT_TYPES.CAMERA_CHANGED].addEventListener(
      debounce(this.handleCameraChanged, 300)
    );
  }

  /**
   * 左键点击事件处理
   * @param {cesium.ScreenSpaceEventHandlerPositionedEvent} movement - 事件对象
   */
  leftClick(movement) {
    this.handlePosition(movement.position, EVENT_TYPES.LEFT_CLICK);
  }

  /**
   * 鼠标移动事件处理
   * @param {cesium.ScreenSpaceEventHandlerPositionedEvent} movement - 事件对象
   */
  mouseMove(movement) {
    this.handlePosition(movement.endPosition, EVENT_TYPES.MOUSE_MOVE);
  }

  /**
   * 右键点击事件处理
   * @param {cesium.ScreenSpaceEventHandlerPositionedEvent} movement - 事件对象
   */
  rightClick(movement) {
    this.handlePosition(movement.position, EVENT_TYPES.RIGHT_CLICK);
  }

  /**
   * 统一处理位置计算与存储(核心逻辑)
   * @param {cesium.Cartesian2|undefined} position - 屏幕坐标(可能为 undefined)
   * @param {string} eventType - 事件类型(来自 EVENT_TYPES)
   */
  handlePosition(position, eventType) {
    if (!cesium.defined(position)) return; // 屏幕坐标无效时跳过
    const cartesian = this.viewer.scene.pickPosition(position);
    if (!cesium.defined(cartesian)) {
      // console.warn('未命中有效地形/模型位置');
      return;
    }

    // 计算经纬度与高度
    const cartographic = cesium.Cartographic.fromCartesian(cartesian);
    this.position = {
      longitude: cesium.Math.toDegrees(cartographic.longitude),
      latitude: cesium.Math.toDegrees(cartographic.latitude),
      height: this.viewer.camera.positionCartographic.height / 1000, // 转换为千米
    };

    // 触发 Store 更新
    const action = STORE_ACTION_MAP[eventType];
    if (action) action(this.position);
  }
  handleCameraChanged() {
    const bounds = getViewBounds(window.viewer);
    // 此处还可以获取相机的方向参数,因此时不需要暂时不写,只获取相机在三维情况下的左上角和右下角的经纬度
    if (bounds) {
      const action = STORE_ACTION_MAP[EVENT_TYPES.CAMERA_CHANGED];
      if (action) action(bounds);
    }
  }
  /**
   * 销毁事件处理器(释放资源)
   */
  destroy() {
    if (this.handler) {
      this.handler.destroy();
      this.handler = null; // 避免内存泄漏
    }
  }
}

// 其他工具类方法
/**
 * 获取当前的图层层级
 * @param {cesium.viewer} viewer
 * @returns 图层层级
 */
function debounce(func, delay) {
  let timeoutId;
  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
}
function getTileLevel(viewer) {
  let tiles = new Set();
  let tilesToRender = viewer.scene.globe._surface._tilesToRender;
  if (cesium.defined(tilesToRender)) {
    for (let i = 0; i < tilesToRender.length; i++) {
      tiles.add(tilesToRender[i].level);
    }
    const levels = Array.from(tiles);
    return Math.max(...levels);
  }
}
/**
 * 获取3D模式下cesium的屏幕经纬度
 * @param {cesium.viewer} viewer
 * @returns 经纬度信息
 */
function getViewBounds(viewer) {
  let bounds = {
    topLeft: { lon: null, lat: null },
    bottomRight: { lon: null, lat: null },
    level: 0,
  };

  // 获取当前视图矩形范围
  const extent = viewer.camera.computeViewRectangle();

  // 在3D模式下计算边界
  if (extent) {
    bounds.topLeft.lon = cesium.Math.toDegrees(extent.west);
    bounds.topLeft.lat = cesium.Math.toDegrees(extent.north);
    bounds.bottomRight.lon = cesium.Math.toDegrees(extent.east);
    bounds.bottomRight.lat = cesium.Math.toDegrees(extent.south);
  }
  bounds.level = getTileLevel(viewer);
  return bounds;
}

通过 Pinia 存储视角数据,方便全局访问:

js 复制代码
import { store } from "@/stores";
import { ref, computed, reactive } from 'vue'
import { defineStore } from 'pinia'
export const useCesiumEventStore = defineStore("cesiumEvent", () => {
  // 鼠标移动的经纬度
  const mouseMovePostion = ref({ longitude: 0, latitude: 0, height: 0 });
  // 鼠标左键点击的经纬度
  const leftClickPosition = ref({ longitude: 0, latitude: 0, height: 0 });
  // 鼠标右键点击的经纬度
  const rightClickPosition = ref({ longitude: 0, latitude: 0, height: 0 });
  // 当前视角所在层级
  const bounds = reactive({
    topLeft: { lon: null, lat: null },
    bottomRight: { lon: null, lat: null },
    level: 0,
  });
  const changeMouseMovePosition = (val) => {
    mouseMovePostion.value = { ...val };
  };
  const changeLeftClickPosition = (val) => {
    leftClickPosition.value = { ...val };
  };
  const changeRightClickPosition = (val) => {
    rightClickPosition.value = { ...val };
  };
  const changeCameraBounds = (val) => {
    bounds.topLeft = val.topLeft;
    bounds.bottomRight = val.bottomRight;
    bounds.level = val.level;
  };
  const position = computed(() => {
    return {
      left: { ...leftClickPosition.value },
      right: { ...rightClickPosition.value },
      mouse: { ...mouseMovePostion.value },
    };
  });
  return {
    mouseMovePostion,
    leftClickPosition,
    rightClickPosition,
    position,
    bounds,
    changeMouseMovePosition,
    changeLeftClickPosition,
    changeRightClickPosition,
    changeCameraBounds,
  };
});
// 组件外使用
export function useCesiumEventStoreHook() {
  return useCesiumEventStore(store);
}

2. 轨迹数据池核心实现

PointDataPools类是整个方案的核心,负责管理轨迹点的创建、更新和复用:

js 复制代码
import * as cesium from "cesium";

/**
 * 点位数据池 - 用于高效管理 Cesium 中点位的批量渲染与更新
 */
export class PointDataPools {
  /**
   * 构造函数
   * @param {cesium.Viewer} viewer Cesium 查看器实例(必填)
   * @param {number} [initialSize=10000] 初始点位池容量(默认 10000)
   */
  constructor(viewer, initialSize = 10000) {
    if (!viewer) throw new Error("Cesium Viewer 实例不能为空");
    this.viewer = viewer;
    this.poolSize = initialSize; // 点位池总容量(已分配的点位数量)
    this.startLabel = null; // 起点标签实体
    this.endLabel = null; // 终点标签实体
    this.primitiveCollection = null; // 底层点位集合(PointPrimitiveCollection)

    this._init();
  }

  /**
   * 初始化核心资源(底层点位集合、标签)
   * @private
   */
  _init() {
    // 初始化底层点位集合
    this.primitiveCollection = new cesium.PointPrimitiveCollection();
    this.viewer.scene.primitives.add(this.primitiveCollection);

    // 初始化标签
    this._createLabels();

    // 预分配初始点位
    this._expandPool(this.poolSize);
  }

  /**
   * 扩展点位池容量(按需扩容)
   * @param {number} count 需要扩展的点位数量(需为正整数)
   * @private
   */
  _expandPool(count) {
    if (!Number.isInteger(count) || count <= 0) {
      console.warn("扩展数量需为正整数,已忽略无效值");
      return;
    }

    // 批量创建新点位(预分配内存提升性能)
    for (let i = 0; i < count; i++) {
      this.primitiveCollection.add(
        new cesium.PointPrimitive({
          position: new cesium.Cartesian3(), // 初始位置(后续更新)
          show: false, // 初始隐藏
          color: cesium.Color.GREEN, // 默认颜色(后续更新)
          pixelSize: 8, // 点位大小
        })
      );
    }
  }

  /**
   * 创建起点/终点标签实体
   * @private
   */
  _createLabels() {
    // 公共标签配置(减少重复代码)
    const commonLabelConfig = {
      font: "16px sans-serif",
      horizontalOrigin: cesium.HorizontalOrigin.LEFT,
      verticalOrigin: cesium.VerticalOrigin.BOTTOM,
      pixelOffset: new cesium.Cartesian2(15, -5),
      fillColor: cesium.Color.WHITE,
      style: cesium.LabelStyle.FILL_AND_OUTLINE,
      outlineWidth: 2,
    };

    // 起点标签
    this.startLabel = this.viewer.entities.add({
      position: new cesium.Cartesian3(),
      label: { ...commonLabelConfig, text: "起点" },
    });

    // 终点标签
    this.endLabel = this.viewer.entities.add({
      position: new cesium.Cartesian3(),
      label: { ...commonLabelConfig, text: "终点" },
    });
  }

  /**
   * 批量更新点位位置与颜色
   * @param {cesium.Cartesian3[]} positions 新的点位坐标数组(需为 Cartesian3 实例数组)
   * @param {string} [color='#67C23A'] 点位颜色(CSS 颜色字符串)
   */
  updatePositionsBatch(positions, color = "#67C23A") {
    // ---------------------- 参数校验(防御性编程) ----------------------
    if (!Array.isArray(positions)) {
      throw new Error("positions 必须是数组类型");
    }
    if (positions.some((p) => !(p instanceof cesium.Cartesian3))) {
      throw new Error("positions 数组必须包含 Cartesian3 实例");
    }
    // ---------------------- 预处理(减少重复计算) ----------------------
    const activeCount = Math.min(positions.length, this.poolSize); // 有效点位数量(不超过池容量)
    const targetColor = cesium.Color.fromCssColorString(color); // 颜色仅转换一次

    // ---------------------- 批量更新点位 ----------------------
    for (let index = 0; index < this.poolSize; index++) {
      const primitive = this.primitiveCollection.get(index);
      if (!primitive) break; // 防止越界(理论上不会触发)

      if (index < activeCount) {
        // 有效点位:更新位置、显示、颜色
        primitive.position = new cesium.Cartesian3(
          positions[index].x,
          positions[index].y,
          positions[index].z
        );
        primitive.show = true;
        primitive.color = targetColor;
      } else {
        // 无效点位:仅当显示时隐藏(避免重复操作已隐藏的点位)
        if (primitive.show) {
          primitive.show = false;
        } else {
          break; // 后续点位已隐藏,提前终止循环
        }
      }
    }
  }

  /**
   * 更新起点/终点标签位置
   * @param {cesium.Cartesian3[]} positions 包含起点和终点的坐标数组(至少 2 个元素)
   */
  updateLabelPositions(positions) {
    // 参数校验
    if (!Array.isArray(positions) || positions.length < 2) {
      throw new Error("positions 需包含至少 2 个坐标点(起点+终点)");
    }

    // 更新起点(第一个坐标)和终点(最后一个坐标)
    this.startLabel.position = positions[0];
    this.endLabel.position = positions[positions.length - 1];
  }

  /**
   * 控制所有点位的显示/隐藏
   * @param {boolean} isVisible 是否显示
   */
  setVisibility(isVisible) {
    if (this.primitiveCollection) {
      this.primitiveCollection.show = isVisible;
    }
  }

  /**
   * 清理所有资源(防止内存泄漏)
   */
  clear() {
    // 移除底层点位集合
    if (this.primitiveCollection) {
      this.viewer.scene.primitives.remove(this.primitiveCollection);
      this.primitiveCollection.destroy();
      this.primitiveCollection = null;
    }

    // 移除标签实体
    if (this.viewer.entities) {
      this.viewer.entities.removeAll();
    }

    // 重置状态
    this.poolSize = 0;
    this.startLabel = null;
    this.endLabel = null;
  }
}

3. 实际使用流程

在 Vue 组件中,通过监听视角变化触发轨迹更新:

js 复制代码
<script setup>
import { PointDataPools } from "@/utils/PointDataPools";
watch(
  bounds,
  async (newVal) => {
    await renderInLocus(newVal);
  },
  { deep: true }
);
const renderInLocus = async (bounds) => {
  if (bounds) {
    const { topLeft, bottomRight, level } = bounds;
    if (level > 9 && mmsi.value) {
      line = [
        [topLeft.lon, topLeft.lat],
        [bottomRight.lon, topLeft.lat],
        [bottomRight.lon, bottomRight.lat],
        [topLeft.lon, bottomRight.lat],
        [topLeft.lon, topLeft.lat],
      ];
      let polygon = turf.lineToPolygon(turf.lineString(line));
      const { geometry } = turf.buffer(polygon, 20, { units: "kilometers", steps: 1 });
      const params = {
        mmsi: mmsi.value,
        geoJson: { ...geometry },
      };
      const { originalIn } = await testAPI.getLocusIn(params);
      // 更新缓冲区内的点
      points.updateLabelPositions(originalC3);
      const c3 = lonlatArrayToCartesian3(originalIn);
      // 更新label点位
      points.updatePositionsBatch(c3, "#F56C6C");
    } else {
      if (c3.length === 0) return;
      points.updateLabelPositions(c3);
      points.updatePositionsBatch(c3);
    }
  }
};
onMounted(async () => {
  points = new PointDataPools(window.viewer);
});
</script>

四、方案优势总结

  1. 性能优化:通过按需渲染减少 80% 以上的渲染压力,帧率稳定在 30+
  2. 资源复用:数据池机制避免了频繁创建 / 销毁 Primitive 导致的内存波动
  3. 流畅体验:缓冲区设计解决了视角移动时的轨迹闪烁问题
  4. 可扩展性:支持动态扩容,可应对不同数据量场景

这种方案不仅适用于船舶轨迹,也可推广到任何需要海量点数据可视化的场景(如车辆轨迹、气象监测点等)。通过合理利用 Cesium 的底层 API 和状态管理,我们也可以处理量级比较大的数据。最后欢迎在留言区留言交流!

相关推荐
ct9782 天前
Cesium高级特效与着色器开发全指南
前端·gis·cesium·着色器
葱明撅腚4 天前
shapely空间数据分析
python·pandas·gis·shapely
极海拾贝5 天前
秒加在线底图!天地图、高德地图、星图地球、吉林一号底图一次配齐,收藏这篇就够了!
arcgis·gis·geoscene
ct9785 天前
Cesium 矩阵系统详解
前端·线性代数·矩阵·gis·webgl
两点王爷5 天前
KML文件格式和支持添加的内容
gis
水静川流6 天前
GIS工具、POI数据、DEM数据、NDVI数据等地学大数据
arcgis·gis·poi·dem·地学大数据
duansamve7 天前
Cesium 线段分割和删除
cesium
GIS遥遥8 天前
2026年地信测绘遥感(3S)专业升学、就业、考证、竞赛专属日历
gis·gis开发·测绘·地图可视化
YAY_tyy8 天前
Cesium 基础:地球场景初始化与视角控制
前端·cesium