拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JSAPIThree SimplePoint实战:三维场景海量散点批量渲染

JSAPIThree SimplePoint实战:三维场景海量散点批量渲染

1. 认识 JSAPIThree 与 SimplePoint:为什么需要在三维场景里撒点

做 WebGIS 的同学应该都有这种体会:二维地图上密密麻麻的标注点,一旦切换到三维场景,传统的地图符号系统就显得非常“假”——点永远面向屏幕,没有衰减,没有空间层次感,整体看起来像贴纸贴在玻璃上。而真实世界的业务数据,比如环境监测站、物流网点、城市设施分布,本质上都是带坐标的散点,在三维场景里需要的是有体积感、有光影、能随视角变化的可视化效果。

JSAPIThree 就是为解决这个问题而生的。它本质上是一个把 ArcGIS API for JavaScript(也就是常说的 JSAPI)和 Three.js 桥接起来的扩展库,让你能在原有的 SceneView 里直接使用 Three.js 的场景图、材质体系和几何体,同时又保留 ArcGIS 平台的地图服务、符号系统和交互能力。它不是什么全新框架,而是一个“胶水层”,但恰恰是这层胶水,把 GIS 的空间参考体系与 Three.js 的渲染能力黏合在了一起。

这次要聊的核心是 SimplePoint。它是 JSAPIThree 里专门用于散点可视化的图层类型,适合把一批已知经纬度坐标的点批量丢到三维场景中渲染。相比逐点创建 Three.js 网格,SimplePoint 走的是批量实例化渲染路线,几百几千甚至上万个点都能保持流畅帧率,内存开销也远小于逐个创建几何体。

这篇笔记适合谁看?如果你正在用 ArcGIS Maps SDK for JavaScript 做三维可视化,被海量点数据的渲染性能卡住,或者想在三维场景里做点和柱状图之外的散点效果,可以参考这份实操记录。我尽量把踩过的坑和调试过程中验证过的参数都写清楚。

2. 加载前的底层逻辑:先搞懂 SceneView 与 Three.js 的坐标系关系

很多人在第一次接触 JSAPIThree 时都会懵:明明我有经纬度坐标,怎么在 Three.js 的笛卡尔坐标系里定位?这就要先理解两个坐标系之间的桥梁是怎么搭起来的。

2.1 经纬度如何映射进 Three.js 场景

JSAPIThree 内部通过RenderCamera对象监听 SceneView 的相机状态,每一次视角变化都会同步更新 Three.js 的 camera 位置和姿态。而业务数据的坐标转换,用的是geographicToCartesian这样的工具方法,它会把你传入的经纬度和高度转换成一个 Three.js 场景内部使用的世界坐标向量。

这个转换意味着什么?意味着你写的代码里不需要手动做墨卡托投影、不需要自己管理层级缩放,JSAPIThree 已经替你把“经纬度 —> 局部世界坐标 —> 屏幕像素”这条链路打通了。你只需要关心业务数据本身,关心点的样式和交互,其余的都交给封装层处理。

不过有一点要注意:JSAPIThree 的坐标转换默认基于 WGS84 地理坐标系。如果你的业务数据是 CGCS2000 或者地方坐标系(比如深圳独立坐标系),一定先去 ArcGIS 平台或者后端做一次坐标转换,把数据统一到 WGS84 经纬度再喂给 SimplePoint,不然点位会漂移到你怀疑人生。

2.2 图层类型选择:为什么是 SimplePoint 而不是 Graphic 或 ObjectLayer

ArcGIS JSAPI 自身也提供了PointCloudLayer、GraphicsLayer、GeoJSONLayer这类方案。这里坦白说一下我的选型结论:

  • 官方GraphicsLayer用 symbol 渲染点,维护简单,但点数量超过 2000 之后,缩放、旋转时的卡顿会非常明显,因为每个 Graphic 都是独立的渲染单元。
  • PointCloudLayer适合处理 las/laz 这类专业点云数据,字段结构和服务要求都比较固定,不太适合直接把业务数据库表里的两列经纬度丢给它。
  • JSAPIThree 的SimplePoint则完全为“业务散点”设计:批量喂坐标、统一样式、GPU 实例化渲染,天然适配几十万量级的点集。

所以如果你只是画三五十个兴趣点,用 GraphicsLayer 没毛病,代码最短;但如果数据量起步就是几千,或者你希望后续给点加贴图、加动画、加聚合,那直接从 SimplePoint 入手是更合适的技术路线。

3. 最小可用实现:把第一批经纬度点撒进三维场景

这一节直接给完整可跑的代码。我用的是当前主流的 ArcGIS Maps SDK for JavaScript 4.x 版本配合@arcgis/core 的 ESM 方式引入,JSAPIThree 使用通过 npm 安装的 jimu-ui 以外的社区版或自行构建的版本,核心 API 思路一致。

3.1 项目初始化与依赖引入

npm init -y npm install @arcgis/core npm install three

JSAPIThree 的引入方式在社区里有多种变体,有的通过 CDN 全局变量方式,有的通过 import 方式。我的项目里是直接 import 对应的模块文件:

import { loadArcGISModules } from '@arcgis/map-components'; // 或根据你的实际路径 import * as jsapiThree from './jsapi-three/index.js';

如果你用的是非官方打包版本,建议把 JSAPIThree 的源码目录放在项目根目录下,然后通过相对路径引入,避免打包工具的模块解析问题。这里我假设你已经有一个初始化好的 SceneView:

import Map from '@arcgis/core/Map'; import SceneView from '@arcgis/core/views/SceneView'; import * as THREE from 'three'; const map = new Map({ basemap: 'dark-gray-vector' }); const view = new SceneView({ container: 'viewDiv', map: map, camera: { position: { x: 116.4, // 北京 y: 39.9, z: 80000 }, heading: 0, tilt: 45 } });

3.2 创建 JSAPIThree 场景并挂载图层

在 SceneView 创建完成后,初始化 JSAPIThree 的渲染控制器,这一步将接管 Three.js 的 render 循环:

let jsapiThreeScene; let simplePointLayer; view.when(() => { // 初始化 JSAPIThree 核心对象 jsapiThreeScene = new jsapiThree.Scene(view); // 创建 Three.js 场景 const threeScene = new THREE.Scene(); threeScene.background = null; // 让 ArcGIS 底图透过来 // 往 JSAPIThree 场景中添加 Three.js 场景 jsapiThreeScene.addScene(threeScene); // 添加一个相机同步器,内部已经处理了view相机同步 });

注意threeScene.background必须设为null,否则会把 ArcGIS 的底图完全遮住,这是新手最容易踩的第一个坑。

3.3 组装 SimplePoint 图层数据

假设数据源是一批站点坐标数组,每条包含longitude、latitude、value三个字段:

const siteData = [ { longitude: 116.391, latitude: 39.907, value: 12.5 }, { longitude: 116.405, latitude: 39.915, value: 8.2 }, { longitude: 116.378, latitude: 39.932, value: 15.7 }, // ... 更多数据 ];

实例化 SimplePoint 图层时,需要指定一个 Three.js 场景作为父容器,然后把坐标数据转换成场景内的世界坐标:

const simplePoint = new jsapiThree.SimplePoint({ scene: threeScene, // 需要以数组形式传入几何数据 geometry: siteData.map(item => { return jsapiThree.geographicToCartesian({ x: item.longitude, y: item.latitude, z: 0 }); }) });

3.4 样式配置与加载进场景

SimplePoint 提供两个核心样式方法:setColor控制颜色,setSize控制点尺寸。你还可以通过pointsMaterial参数定制材质:

const pointsMaterial = new THREE.PointsMaterial({ color: 0xff6600, size: 2, transparent: true, opacity: 0.85, sizeAttenuation: true }); simplePoint.setMaterial(pointsMaterial); simplePoint.setSize(2);

最后把图层挂到场景中:

jsapiThreeScene.add(simplePoint);

到这里,第一批散点已经出现在三维地球上了。跑通之后你会看到,散点有纵深、有大小衰减,拉近视角时点会变大,拉远时变小——这就是 sizeAttenuation 的效果。

4. 参数深挖与效果调优:散点图表的灵魂在于细节

跑通最小实现之后,真正拉开效果差距的是参数调优。这一节把几个核心参数逐个过一遍,并解释每个参数背后的渲染原理,方便你按需调整。

4.1 点的颜色映射与数据驱动

业务场景中散点的颜色往往需要按照属性值分级显示,而不是全部统一色。SimplePoint 支持给每个点单独指定颜色,做法是遍历几何数据,为每条记录设置独立的颜色分量:

const colors = siteData.map(item => { if (item.value > 15) return new THREE.Color(0xff0000); if (item.value > 10) return new THREE.Color(0xffaa00); return new THREE.Color(0x00ccff); }); simplePoint.setColors(colors);

注意:颜色数组的长度必须与几何数组长度一致,否则 Three.js 会按照默认规则补色或抛错。这种点级颜色的性能开销比整体material.color大一些,因为每条顶点颜色都要参与 GPU 插值,但在万级点量级帧率依然稳定。

4.2 sizeAttenuation 的三维感知与视觉陷阱

sizeAttenuation是 PointsMaterial 的一个布尔属性。为true时,点的大小随相机距离缩放,形成真实透视;为false时,所有点的屏幕像素尺寸恒定。

我做环境监测站点可视化时,强烈建议开启sizeAttenuation,否则从低空一直拉到全球视角,点的大小完全没有变化,三维感会大打折扣。但如果你要在城市级场景里叠加路网点,且点只是辅助参考,可以考虑关闭衰减,保证缩放到街景层级时点不会大到挡住路网。

经验值:在视角高度 20 公里到 50 公里之间漫游时,size 取值 1.5-2.5 视觉最舒服;如果拉低到地面高度,需要把 size 同步调低到 0.5 以下,否则点会糊成一片光斑。

4.3 透明度混合模式的两个选择

散点重叠在底图上时,透明度设置会直接影响信息可读性。Three.js 的transparent属性开启后,你还要理解默认材质使用的混合模式是NormalBlending,它适合大多数情况。但如果你的点数量很多且相互遮挡严重,可能出现叠影发白的问题。

我习惯调整材质的 depthWrite 属性:

pointsMaterial.depthWrite = false;

这样点的深度信息不会写入深度缓冲,后绘制的点不会因为深度测试不通过而消失,透明叠加效果更干净。代价是遮挡关系判断会乱一点,但对于散点来说视觉干扰极小,值得一试。

4.4 视觉层次的高级做法:双图层叠加

单一 SimplePoint 图层做出来的效果始终有点“平”。我的进阶做法是:同一条业务数据创建两个 SimplePoint,分别设置不同大小和透明度,一个作为泛光底层,一个作为核心点层。底层点大小设为顶层的 2.5 倍,透明度设为 0.3;顶层透明度 0.9。

这样形成的视觉效果在暗色底图上尤其出彩,点边缘有一圈光晕,不需要额外写 shader,性能开销也只是两个实例化图层而已。这个方法我推荐每个做三维散点可视化的同学都试一次,视觉提升是肉眼可见的。

5. 把散点做得“像地图标注”:从点到贴图的进阶玩法

散点可视化做得多了,你会发现纯圆点的信息承载能力非常有限——没有文字、没有图标、没有层次感。如果只是把三维当背景板,又回到了“伪三维”的老路。这一节把热词里的“img+标签+点击跳出图层”结合进去,分享我在 SimplePoint 基础上叠加贴图和交互的实践经验。

5.1 自定义贴图替代圆形

SimplePoint 的核心是THREE.PointsMaterial,所以它天然支持map贴图。你可以把业务图标(快递站点、设备标识、品牌 Logo)做成一张 PNG 贴图,替换掉默认的圆形点。

const textureLoader = new THREE.TextureLoader(); const iconTexture = textureLoader.load('./icons/station.png'); const materials = [ new THREE.PointsMaterial({ map: iconTexture, size: 6, transparent: true }) ]; simplePoint.setMaterial(materials);

这里有个细节:贴图如果要保持透明度,PNG 资源必须带 alpha 通道,且材质必须transparent: true。踩过的一次坑是贴图加载完成后忘记调用texture.needsUpdate = true,导致首次渲染时图标没有显示,后来重新加载一次才正常。这个问题在 WebGL 纹理上传时机没对上时会被触发,稳妥做法是在texture.onLoad回调里执行一次强制刷新、再触发场景重绘。

还有一点:官方推荐的贴图尺寸通常是 2 的幂次方,比如 64x64、128x128。非 2 的幂次方纹理在部分移动设备上有兼容性问题,有些 WebGL 实现会直接拒绝采样。最稳的方案是让设计同学输出 128x128 的 PNG,兼顾清晰度和兼容性。

5.2 screenPoint 实现:贴图标签怎样跳出“屏幕”

现在点有了图标,但你还需要给每个点配一个类似地图 POI 的标签文字。SimplePoint 本身不支持文字渲染,两个常见方案:

  1. 把文字做成纹理,挂到点上,随点渲染,缺点是每个点需要单独一张材质,批次增加性能下滑。
  2. 用 HTML 元素叠加:监听 view 的相机变化事件,把每个点的三维坐标投影为屏幕坐标,再用绝对定位的 DOM 标签显示。

实际项目中我选择了第二个方案。投影公式在 ArcGIS JSAPI 中有现成方法:

view.watch('camera', () => { siteData.forEach((item, index) => { const screenPoint = view.project({ x: item.longitude, y: item.latitude, z: 0 }); const label = document.getElementById(`label-${index}`); label.style.left = screenPoint.x + 'px'; label.style.top = screenPoint.y + 'px'; }); });

为了实现“点击跳出图层”,我给每个 DOM 标签绑定了 click 事件,点击后弹出一个浮层组件,展示该散点的详细业务数据:

label.addEventListener('click', () => { // 打开详情弹窗 showDetailDialog(item); });

这套方案实现简单,效果稳定。唯一需要注意的是事件的节流与销毁:相机连续缩放时,view.project会高频触发,如果不做节流会导致 DOM 操作频繁、页面卡顿。我用的是requestAnimationFrame配合一个 dirty 标记,只在相机停止变化后统一做一次投影刷新。

5.3 Picking 机制:三维世界里的点击交互实现

如果你不想用 DOM 模拟点击,而是想直接在 Three.js 场景里拾取点对象,也可以。关键在于让每个散点几何体携带业务 ID:

// 用 BufferGeometry 保存索引 const idAttribute = new THREE.BufferAttribute( new Float32Array(siteData.map((_, idx) => idx)), 1 ); simplePoint.geometry.setAttribute('businessId', idAttribute);

这样在鼠标点击时,你可以通过射线检测 Raycaster 得到点的索引,进而反查业务数据源。射线检测在 SimplePoint 上的性能开销要比在普通 Mesh 上大不少,而且大规模点时命中率受像素大小影响,需要手动调节Raycaster.params.Points.threshold:

raycaster.params.Points.threshold = 2;

经验之谈:如果点的 size 比较小(小于 3 像素),点击命中率会很低,此时把 threshold 设置为中心距离 5 像素左右比较合适。但阈值太大会误触相邻点,所以调到 3-5 之间需要根据实际视角高度反复试,没有统一答案。

6. 从零到一:处理“已知坐标点加入图层”的完整流程

热词里提到的“arcgis已知坐标的点加入图层”是一个很典型的业务诉求:我手上有一张表,表里有两列经纬度,我要把它们批量变成三维场景里的点要素。这一节把完整的处理链路写清楚,数据从数组、CSV 到图层对象全程走通。

6.1 数据清洗与坐标范围检查

拿到原始坐标数据,第一步不是写代码,而是检查脏数据。我常见的问题:

  • 经纬度字段是字符串,里面混了中文逗号。
  • 经纬度顺序颠倒(有人习惯写纬度,经度)。
  • 坐标落在海里、国境线外,明显是采集设备漂移点。
  • 存在重复坐标点。

清洗代码很简单,但必须和业务方确认过滤规则:

const validPoints = rawData .filter(d => { const lon = parseFloat(d.lon); const lat = parseFloat(d.lat); return !isNaN(lon) && !isNaN(lat) && lon > -180 && lon < 180 && lat > -90 && lat < 90; }) .map(d => ({ x: parseFloat(d.lon), y: parseFloat(d.lat), z: d.height ? parseFloat(d.height) : 0, attributes: d }));

这一段看起来很基础,但能直接决定后续可视化的成败。坏坐标数据进入图层之后,往往很难通过视觉排查出来,因为散点会出现在完全莫名其妙的位置,你会花大量时间调试图层代码而忽略根本原因是数据源就有问题。

6.2 CSV 数据加载场景

如果需要加载 CSV 文件,可以直接用 axios 解析文本,再用简单的 split 逻辑拆出行和列:

const response = await axios.get('/data/sites.csv'); const lines = response.data.split('\n'); const headers = lines[0].split(','); const parsedData = lines.slice(1).map(line => { const values = line.split(','); const obj = {}; headers.forEach((h, i) => { obj[h.trim()] = values[i].trim(); }); return obj; });

如果 CSV 文件有几万行,这种纯前端的解析其实也很快,主要瓶颈在渲染而不在解析。但如果 CSV 是 GBK 编码而且带 BOM,需要先用 TextDecoder 指定编码转换一次,否则表头和中文内容会乱码。

6.3 动态给图层追加点

除了初始化时一次性传入,SimplePoint 还支持后续追加数据。如果业务场景是实时数据流(比如车辆 GPS 轨迹点不断上报),你需要维护一个数据数组,并调用图层的数据更新方法。这里给一个追加点的封装:

function appendPointToLayer(layer, pointData) { const cartesianPos = jsapiThree.geographicToCartesian({ x: pointData.longitude, y: pointData.latitude, z: pointData.altitude || 0 }); const positions = layer.geometry.attributes.position.array; const newPositions = new Float32Array(positions.length + 3); newPositions.set(positions); newPositions.set([cartesianPos.x, cartesianPos.y, cartesianPos.z], positions.length); layer.geometry.setAttribute('position', new THREE.BufferAttribute(newPositions, 3)); layer.geometry.computeBoundingSphere(); layer.geometry.attributes.position.needsUpdate = true; }

这个手动更新 BufferGeometry 的方式省去了重新创建图层的开销,实测在动态追加场景(每秒 10 个点)下帧率稳定。有个小坑是追加数据后computeBoundingSphere必须执行,否则 Three.js 的视锥剔除会把新加的点意外裁剪掉,现象就是点突然消失又出现,排查起来很有迷惑性。

7. 常见问题与排查技巧实录

这一节是实际操作中最容易遇到的坑,整理成速查形式,按症状、原因、解决方案列表区分,方便你遇到问题时快速定位。

7.1 常见问题速查表

现象可能原因解决方案
散点完全不显示图层未添加到场景或坐标转换失败检查jsapiThreeScene.add(simplePoint);打印geographicToCartesian输出值是否为有限数
散点全部显示在角落/地球内部WGS84 坐标和 scene 相机坐标系不一致确认数据为经纬度,非投影坐标;检查是否把投影坐标当经纬度传入
透明度效果不对,叠影发白深度写入导致混合错乱设置depthWrite = false,调大opacity到 0.8 以上
点数量多后帧率骤降非实例化渲染或重复创建了点对象确认使用的 SimplePoint 内部是否走 BufferGeometry 批量渲染,避免每点单独创建 Mesh
贴图加载不出来纹理尺寸非 2 的幂转成 128x128 或 512x512 PNG
点击命中率很低Raycaster 阈值过小增大raycaster.params.Points.threshold到 3-5
相机漫游时标签漂移view.camera 监听未节流用 rAF 合并高频事件,只在相机稳定后更新 DOM
缩放摄像机后点大小变化太夸张sizeAttenuation 效果过强调低 size,或关闭 sizeAttenuation 换成恒定大小的展示方式

7.2 一个印象深刻的调试案例

有次做全国城市气候观测站点可视化,城市点数接近 9000 个。带过来时数据本身没问题,但跑到西北区域时出现了大片点莫名消失的现象,第一反应怀疑是数据缺失,后来逐个排查发现是computeBoundingSphere没更新,导致视锥剔除把远离中心点的大片散点当作不可见剔除了。业务数据覆盖范围跨越整个中国,而包围球的半径还停留在最初几百公里的范围,那些“超出包围球”的点自然就消失了。

解决方案是在设置完整批坐标后统一执行一次:

layer.geometry.computeBoundingSphere();

这一行代码拯救了那一次上线。凡是遇到“点分布广而且一部分点消失”的问题,先检查包围球,别急着怀疑数据或材质。

7.3 移动端与兼容性排查

我在移动端测试时遇到过一次渲染不出来的问题,原因是 Three.js 的 WebGL context 创建失败,低端安卓机对 WebGL2 支持不完整。排查时可以先看浏览器 console 是否输出 WebGL 相关错误,然后强制走 WebGL1 渲染路径,或者降低点的数量与粒子大小。

另外移动端帧率敏感,不建议在速度低于 60FPS 的设备上开启大量透明叠加。实测中 5000 个透明散点在 iPhone 上掉帧明显,改成不透明点或减少 60% 的散点数量之后流畅度恢复。这个结论不一定适用于所有设备,但可以在你的项目里做一个基准测试。

8. 我沉淀下来的三点经验

JSAPIThree 的 SimplePoint 学习曲线不算陡峭,但真正把散点做到好用、好看、好维护,需要在渲染和业务之间做取舍。以下是这段实践后个人沉淀的三条经验:

第一,散点可视化不只是一个图层代码的事。它牵扯到坐标数据质量、三维场景的相机设计、灯光与底色配合,甚至后端的坐标转换。任何一环出问题,都会在可视化层暴露出诡异的症状。所以拿到需求时不要闷头写代码,先花二十分钟把数据边界条件列清楚。

第二,性能优化的核心思路是“合并与复用”。SimplePoint 之所以能扛大数据量,本质上就是把所有点合并成同一个 BufferGeometry、复用同一份材质、合并同批次渲染。你自己写 Three.js 逻辑时也要遵循这个思路,避免过多独立对象破坏引擎的批量渲染优化。

第三,交互上不要把一切推给 WebGL。DOM 覆盖层配合屏幕坐标投影,在散点这种稀疏分布的可视化场景里,开发效率、维护成本和用户体验都是更优解。射线检测适合极少数精细操作,不适合作为标注和弹窗的主要交互手段。

最后再分享一个小技巧:如果你希望散点效果在演示时更出片,可以在 SceneView 的相机上设置一个缓慢的自动旋转动画,让散点在不同光照方向下呈现出立体感变化。代码量不到十行,效果却非常显著。这个玩法后续还可以扩展成飞行路径巡检、点位历史轨迹回放等更复杂的业务场景,思路都是一样的:以 SimplePoint 为底,用 Three.js 的能力做加法。

返回列表