做通信运维的可视化这几年,我经手的项目里十有七八最终都会拐到同一个问题上:二维拓扑图画得再漂亮,值班的兄弟在告警响起来的时候还是得靠“设备名搜索 + 台账翻查”去定位那台设备到底在哪个机房、哪一排机柜、哪一层。SpringBoot 配 Three.js 这套组合,是我这两年里用得最顺手的一条技术路线——后端拿 SpringBoot 做数据聚合与实时推送,前端用 Three.js 把机房、基站、链路、告警统统塞进一个可漫游的三维场景里。它解决的不是“炫酷”的问题,而是把“空间位置”这个维度还给运维人员,让“这台设备在哪、这条链路从哪到哪、这条告警影响了谁”变成一眼可见的事。这篇内容适合三类人看:正在评估三维可视化方案的后端同学、被要求“把大屏做起来”的前端同学,以及需要判断这套东西到底值不值得投入的负责人。我会把选型理由、数据模型、坐标映射、批量渲染、性能优化和踩过的坑都摊开讲,代码能抄的部分直接给。
1. 场景拆解:智慧通讯为什么非要上三维
1.1 二维拓扑图到底卡在哪一步
先说清楚痛点,不然很容易变成“为了三维而三维”。通信场景里的资产天然带空间属性:一个园区有若干栋楼,一栋楼有若干层,一层有机房,机房里有机柜、机柜里有 U 位、U 位上插着交换机或者传输设备,设备之间还要通过光缆、跳线连成链路。二维图能表达“谁连谁”,但表达不了“谁和谁在同一排、走线绕不绕、故障半径有多大”。
我印象最深的一次是某园区的汇聚设备告警,二维图上只显示两个红点,值班同事花了几分钟才搞清楚这两个点其实是同一台机柜上下相邻的两台设备,属于同一条供电回路——如果场景是三维的,机柜一拉开,位置关系一目了然,判断时间能压到几十秒。这不是夸张,空间信息的价值就在于它能直接压缩“关联判断”的成本。所以这个项目的核心诉求,从来不是做一个好看的模型展厅,而是把拓扑关系、资产台账、告警状态三者叠在同一个空间坐标系下。
1.2 平台要啃下来的四类硬需求
把需求摊平,其实就是四件事,每件事背后对应一套不同的技术处理方式。
第一类是设备定位:输入设备编号,相机要能自动飞过去,并把目标设备高亮。这要求设备在三维场景里有稳定的唯一坐标,且相机动画不能穿墙穿地板。第二类是链路追踪:给定源设备和目标设备,把中间经过的光缆、跳线、端口全部还原成一条可发光的路径。这要求后端能把链路关系组织成图结构,前端能用折线段渲染。第三类是告警聚焦:告警产生时,场景里对应设备要有明显的视觉反馈,并且要能一层层下钻(园区→楼栋→楼层→机房→机柜→设备)。第四类是容量与状态评估:机柜剩余 U 位、端口占用率、链路带宽利用率,这些数值要能映射成颜色或高度。
这四类需求对数据的要求完全不同:定位要的是精确坐标,链路要的是关系,告警要的是实时性,容量要的是聚合统计。指望一个接口全包是不现实的,后面讲后端接口设计时我会拆开说。
1.3 为什么最终落在 SpringBoot + Three.js
选型我对比过几轮。B 端三维方案常见的有 Three.js、Babylon.js,以及一些商用的 WebGL 引擎。Babylon.js 的工程化程度其实更高,自带 Inspector 调试面板和物理引擎,但社区里能直接搜到的通信行业案例少,遇到坑基本靠自己啃文档。Three.js 的优势是生态足够厚,几何体、材质、后处理、加载器几乎任何需求都能找到现成实现,遇到问题搜索命中率明显更高;缺点是它更像一套“图形库”而非“引擎”,场景管理、资源回收这些都要自己搭骨架。
后端选 SpringBoot 几乎是必然的——通信行业存量系统里 Java 占比很高,对方提供的资产台账、告警系统、性能数据接口大概率是 HTTP + JSON 或者提供 SDK,SpringBoot 做一层聚合与协议转换最省事,而且内置的 WebSocket 支持能直接把实时告警推到前端。
注意:如果你的数据源是 Kafka 之类的消息队列,SpringBoot 侧用
@KafkaListener消费后再转 WebSocket 下发,比让前端直连消息队列安全得多,也便于做权限过滤。
至于为什么不选“后端渲染出图 + 前端只看视频流”的方案:那种方案交互性几乎为零,用户没法自由旋转、没法点选设备,运维场景里“点一下看看这台设备什么状态”是刚需,必须前端实时渲染。
2. 整体架构与数据流怎么切
2.1 前后端职责边界要提前划死
这一步如果含糊,后面一定返工。我的划分原则是:后端负责“是什么”,前端负责“长什么样”。
后端产出的是纯语义数据——设备 ID、名称、类型、所属机柜、三维坐标、状态码、告警级别、链路的两端点。前端拿到这些之后,决定用立方体还是用加载的模型、用什么颜色、要不要加呼吸灯。这条线一旦划清,换皮肤、改配色、加动效都不需要后端动一行代码。
反过来,前端绝对不要去算“这台设备属于哪个机房”“这条链路经过哪几个跳接点”,这类关系运算放在前端做,一是重复计算浪费,二是权限控制会失效——用户没权限看的设备,如果前端能通过关系推导出来,等于白做了权限。
经验上,我会在接口契约里明确三类字段:geometry(位置与尺寸,前端渲染必需)、style(状态码与主题标签,前端据此映射样式)、relations(父子与连接关系,前端用于组织场景树)。三者的更新频率完全不同,缓存的粒度也不同。
2.2 数据模型:设备、链路、告警三张主表
数据库设计不需要太花哨,三张主表加两张辅助表就能撑起大部分场景。设备表是核心,我通常会把坐标拆成“逻辑坐标”和“物理坐标”两套:逻辑坐标是园区/楼栋/楼层/机柜/起始U位的层级路径,物理坐标是最终算出来的三维世界坐标。前者便于业务查询和权限过滤,后者直接给前端用。
CREATE TABLE dev_node ( id BIGINT PRIMARY KEY, dev_code VARCHAR(64) NOT NULL COMMENT '设备唯一编号', dev_name VARCHAR(128) NOT NULL, dev_type VARCHAR(32) NOT NULL COMMENT 'switch/router/olt/odf', site_id BIGINT NOT NULL COMMENT '园区', building_id BIGINT NOT NULL COMMENT '楼栋', floor_no INT NOT NULL COMMENT '楼层', room_id BIGINT NOT NULL COMMENT '机房', rack_id BIGINT NULL COMMENT '机柜,室外设备为空', u_start INT NULL COMMENT '起始U位', u_height INT NULL COMMENT '占高U数', pos_x DECIMAL(10,3) NULL COMMENT '世界坐标X,机房外设备由算法生成', pos_y DECIMAL(10,3) NULL, pos_z DECIMAL(10,3) NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0正常1告警2离线', update_time DATETIME NOT NULL ); CREATE TABLE dev_link ( id BIGINT PRIMARY KEY, src_dev_id BIGINT NOT NULL, src_port VARCHAR(32), dst_dev_id BIGINT NOT NULL, dst_port VARCHAR(32), link_type VARCHAR(16) NOT NULL COMMENT 'fiber/copper/jumper', bandwidth INT COMMENT 'Mbps', util_rate DECIMAL(5,2) COMMENT '利用率百分比', path_json TEXT COMMENT '途经拐点的坐标数组,JSON数组' ); CREATE TABLE dev_alarm ( id BIGINT PRIMARY KEY, dev_id BIGINT NOT NULL, alarm_code VARCHAR(32), level TINYINT NOT NULL COMMENT '1紧急2重要3次要4提示', content VARCHAR(512), occur_time DATETIME NOT NULL, clear_time DATETIME NULL );path_json这个字段值得单独说一句。光缆在实际机房里是走桥架、绕线槽的,两点之间拉一条直线在视觉上是失真的。我们让施工方在台账里顺手补几个拐点坐标,前端用这些点生成折线,真实感立刻上一个档次。补录成本不高,效果提升明显。
2.3 实时通道:WebSocket 与轮询的取舍
告警是实时的,但“实时”在实际业务里的量级并不高——一个中等规模园区,一天告警上千条,摊到每秒其实很低。所以纯 WebSocket 和定时轮询在带宽上都扛得住,关键差异在体验和实现复杂度。
我最终选的是“WebSocket 推增量 + 定时拉全量兜底”。理由很实际:WebSocket 连接本身会因为网络抖动、页面切到后台、代理超时等原因断掉,如果只靠推送,一旦断连用户看到的就是一个“冻结”的场景,比不实时更危险。加一个 60 秒的全量状态拉取,断连期间最多延迟一分钟,视觉上也能自愈。
@Component public class AlarmPushTask { private final SimpMessagingTemplate template; private final DevAlarmMapper alarmMapper; // 新告警落库后调用 public void pushNewAlarm(DevAlarm alarm) { AlarmVO vo = AlarmVO.of(alarm); template.convertAndSend("/topic/alarm", vo); } // 每60秒同步一次全量在线状态,供前端校正 @Scheduled(fixedDelay = 60_000) public void syncSnapshot() { List<DevStatusVO> list = alarmMapper.selectAllDevStatus(); template.convertAndSend("/topic/snapshot", list); } }这里有个细节:convertAndSend发送的对象会被 Jackson 序列化,如果 VO 里带了LocalDateTime,记得配JavaTimeModule,否则前端收到的是数组而不是字符串,解析会出错。这个坑我踩过不止一次。
2.4 模型与静态资源托管别塞进 SpringBoot
三维场景少不了 glTF/GLB 模型文件,一套机房模型动辄几十兆。把这些文件放在 SpringBoot 的static目录下是最省事的做法,但生产环境千万别这么干——应用重启、打包体积、静态资源缓存策略都会成为麻烦。
常规做法是把模型和贴图放到对象存储或者独立的静态文件服务,SpringBoot 只提供元数据接口(模型 URL、包围盒尺寸、锚点偏移)。前端加载器直接走 CDN 地址,既能享受浏览器缓存,也不占用应用服务器带宽。如果内网环境没有对象存储,用 Nginx 单独挂一个静态目录也完全够用,记得开 gzip 和正确的 MIME 类型——.glb要配model/gltf-binary,.ktx2配image/ktx2,配错了浏览器会直接加载失败,控制台报个语焉不详的错误。
3. 后端落地:接口怎么设计才好用
3.1 工程结构与依赖清单
我在做这类项目时习惯把工程切成三层:api(对外接口)、domain(设备链路告警的领域模型与查询)、push(WebSocket 与会话管理)。不要一开始就上微服务,单体够用,切得细反而增加部署成本。
依赖上其实很克制,Spring Web、Spring WebSocket、MyBatis-Plus 或者 JPA、再加一个jackson-datatype-jsr310处理时间类型。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>com.fasterxml.jackson.datatype</groupId> <artifactId>jackson-datatype-jsr310</artifactId> </dependency>除此之外不要贪多,尤其别为了让“接口好看”引入一堆网关、链路追踪组件——三维可视化的性能瓶颈几乎全在前端渲染,后端只要不拖后腿就行。
3.2 场景接口:一次请求把一层的设备拿全
前端的加载逻辑是分层的:进入园区先加载楼栋外轮廓,点进某栋楼再加载楼层,点进楼层再加载该层机柜和设备。所以接口设计也按层级来,不要做一个“返回全部数据”的大接口。
@RestController @RequestMapping("/api/scene") public class SceneController { @GetMapping("/floor/{floorId}/devices") public Result<List<DevNodeVO>> listFloorDevices(@PathVariable Long floorId) { // 只返回渲染必需的字段,避免把台账全字段吐出去 return Result.ok(devNodeService.listByFloor(floorId)); } @GetMapping("/device/{devCode}/locate") public Result<CameraTargetVO> locate(@PathVariable String devCode) { DevNode dev = devNodeService.getByCode(devCode); if (dev == null) return Result.fail("设备不存在"); return Result.ok(new CameraTargetVO(dev.getPosX(), dev.getPosY(), dev.getPosZ(), dev.getRoomId())); } }DevNodeVO我建议只保留code / name / type / x / y / z / status / rackId / uStart / uHeight这几项。之前有一次图省事,直接把实体类返回了,一个楼层 800 台设备,响应体接近 3MB,前端解析加上 Three.js 建对象,页面卡了三四秒。砍掉冗余字段后降到 200KB 以内,加载体感完全不一样。
3.3 链路查询:把关系算清楚再给前端
链路查询是最容易被低估的一环。如果表里只存了“端到端”的两条记录,前端就没法知道中间经过了哪些跳接点。解决办法是在写入时就把完整路径存下来,查询时按端点查出链路,再把path_json解析成坐标数组。
public LinkPathVO getLinkPath(Long srcId, Long dstId) { DevLink link = linkMapper.selectByEndpoints(srcId, dstId); if (link == null) throw new BizException("链路不存在"); List<Point3D> points = new ArrayList<>(); points.add(devNodeService.getPoint(srcId)); points.addAll(JsonUtil.parsePoints(link.getPathJson())); points.add(devNodeService.getPoint(dstId)); return new LinkPathVO(link.getId(), points, link.getUtilRate()); }这里有个性能上的小优化:链路的拐点数组如果每条都从数据库读 TEXT 字段,量大的时候 IO 会很重。我们把path_json在服务启动时预热到一个本地缓存(设备数量有限,几百到几千条链路,内存完全吃得下),查询直接走内存,响应时间从 20ms 降到 2ms 以内。
3.4 用聚合接口顶替“前端自己算”
容量评估这块,前端最怕的是“给一万台设备让我自己算每台机柜的占用率”。这种聚合必须后端做。
@GetMapping("/capacity/racks") public Result<List<RackCapacityVO>> rackCapacity(@RequestParam Long roomId) { // 一次 SQL 聚合出每个机柜的总U数、已用U数、设备数量、告警数 return Result.ok(rackMapper.aggregateByRoom(roomId)); }对应的 SQL 大致是这样,用LEFT JOIN保证没有设备的空机柜也能查出来:
SELECT r.id AS rackId, r.total_u AS totalU, COALESCE(SUM(d.u_height), 0) AS usedU, COUNT(d.id) AS devCount, SUM(CASE WHEN d.status = 1 THEN 1 ELSE 0 END) AS alarmCount FROM rack_info r LEFT JOIN dev_node d ON d.rack_id = r.id WHERE r.room_id = #{roomId} GROUP BY r.id, r.total_u;前端拿到usedU / totalU直接映射成颜色梯度:低于 60% 绿色,60% 到 85% 黄色,85% 以上红色。这个映射规则放在前端配置里,改阈值不用发版。
4. 前端落地:Three.js 场景怎么搭才不崩
4.1 初始化参数,每一个都有讲究
场景初始化的代码网上一搜一大把,但参数填错就会掉进坑里。我列一下自己固定用的配置和理由。
const scene = new THREE.Scene(); scene.background = new THREE.Color(0x0a1421); const camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 2000); camera.position.set(30, 25, 30); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance', stencil: false // 用不到模板缓冲就关掉,省显存 }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(w, h); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0;几个参数的解释:near设 0.1、far设 2000,是因为我们的场景尺度大约是几十米量级,相机可能贴近设备也可能拉到能看到整个园区,深度范围跨度大。如果far设得过大(比如 100000),深度缓冲精度会不够,远处的设备会出现“闪烁撕裂”的 Z-fighting,这个现象我第一次遇到时排查了半天,以为是模型问题。
setPixelRatio一定要夹到 2 以内。有些高分屏设备devicePixelRatio是 3,渲染像素量是 CSS 像素的 9 倍,帧率直接腰斩,而视觉上根本看不出差别。
4.2 坐标映射:从台账经纬度到三维世界
这是整个项目里最容易出错、也最需要写清楚的一块。我们的设备坐标来源有三种:机房内设备有精确的机柜 U 位,室外基站有经纬度,还有一些只有相对位置。三种坐标必须归一到同一个世界坐标系。
机房内的映射最简单,本质是线性变换。假设一个机柜宽 0.6 米、深 1.2 米、高 2 米,42U,那么第 n 台设备的 Y 坐标就是:
y = uStart × (rackHeight / totalU) + (uHeight × rackHeight / totalU) / 242U 机柜总高 2 米,每 U 约 0.0476 米,一台 2U 设备起始 U 位是 10,它的中心高度就是10 × 0.0476 + 2 × 0.0476 / 2 ≈ 0.524 米。这种换算一定要在后端做一次存库,前端不要每次渲染都算,一是容易算出浮点误差,二是改机柜高度时前端得跟着改。
室外经纬度到世界坐标的映射,小范围(几公里内)用等距圆柱投影近似就够了,误差可以忽略:
const EARTH_R = 6378137; function lngLatToWorld(lng, lat, originLng, originLat, scale = 1) { const x = (lng - originLng) * Math.PI / 180 * EARTH_R * Math.cos(originLat * Math.PI / 180); const z = -(lat - originLat) * Math.PI / 180 * EARTH_R; return new THREE.Vector3(x * scale, 0, z * scale); }注意scale这个参数。真实距离如果 1:1 映射,一个 5 公里的园区在三维里就是 5000 个单位,相机移动、雾效、LOD 的阈值全都要跟着调整,非常别扭。我一般会压缩到 1:20 左右,视觉上仍然是“远”,但数值范围好控制得多。当然压缩后要保证高度方向也同步压缩,否则楼栋会显得又扁又宽。
注意:
Math.cos(originLat * ...)这一项千万别漏,否则南北向和东西向的比例会失真。在中高纬度地区,这个误差能到 30% 以上,表现在场景里就是整个园区被“拉长”了。
4.3 模型加载与轻量化,别把 80MB 的原模直接扔上去
机柜、交换机这些设备,我优先用程序化几何体生成,而不是加载模型。理由是:一个立方体加几张不同颜色的面,就能表达设备状态,加载成本几乎为零;而一个精细的 glTF 模型动辄几百 KB,几百台设备叠加起来就是几十兆。真正需要模型的只有楼栋外壳、机柜框架和少量重点设备。
必须用模型时,导出环节就要做减面。我们的流程是:建模软件里减面到 5000 三角面以内 → 导出 glTF → 用 Draco 压缩几何体 → 贴图转 KTX2。一套机柜模型从 3.2MB 压到 380KB,视觉差异在有雾效和光照的场景里基本看不出来。
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'; import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js'; const draco = new DRACOLoader().setDecoderPath('/libs/draco/'); const ktx2 = new KTX2Loader().setTranscoderPath('/libs/basis/') .detectSupport(renderer); const loader = new GLTFLoader() .setDRACOLoader(draco) .setKTX2Loader(ktx2); loader.load('/models/rack.glb', (gltf) => { gltf.scene.traverse((o) => { if (o.isMesh) { o.castShadow = false; // 室内场景不需要投影,省一半开销 o.receiveShadow = false; o.frustumCulled = true; } }); scene.add(gltf.scene); });castShadow和receiveShadow这两个开关,是我在性能优化阶段最先动的地方。开了实时阴影,渲染管线要多跑一遍阴影贴图,几百个对象时帧率能掉三分之一,而在机房这种打光均匀的环境里,阴影几乎看不出区别,直接关掉。
4.4 批量渲染:5000 台设备不能是 5000 个 Mesh
这是 Three.js 做 B 端场景最核心的性能点。如果每台设备new THREE.Mesh(...),那就有 5000 个 draw call,浏览器主线程会被拖垮。正确做法是InstancedMesh。
const geo = new THREE.BoxGeometry(0.6, 0.09, 0.6); const mat = new THREE.MeshStandardMaterial({ color: 0xffffff }); const MAX = 6000; const inst = new THREE.InstancedMesh(geo, mat, MAX); inst.instanceMatrix.setUsage(THREE.DynamicDrawUsage); inst.instanceColor = new THREE.InstancedBufferAttribute( new Float32Array(MAX * 3), 3 ); const dummy = new THREE.Object3D(); const color = new THREE.Color(); function buildDevices(list) { list.forEach((d, i) => { dummy.position.set(d.x, d.y, d.z); dummy.scale.set(1, d.uHeight, 1); dummy.updateMatrix(); inst.setMatrixAt(i, dummy.matrix); color.setHex(statusColor(d.status)); inst.setColorAt(i, color); }); inst.count = list.length; inst.instanceMatrix.needsUpdate = true; inst.instanceColor.needsUpdate = true; }关键点有三个:一是instanceColor必须手动创建,默认没有;二是count要在填充后设置,否则会渲染出多余的实例(矩阵全是 0,表现为原点处堆一堆盒子);三是dynamicDrawUsage只在状态频繁变化时才设,如果设备是静态的,用默认的静态用法反而更快。
着色器里instanceColor要和材质颜色相乘,所以材质的基础色必须设为白色,否则颜色会偏。这个细节我第一次用时没注意,红色告警在场景里显示成了暗红,查了很久才发现是材质基色带来的颜色污染。
链路用LineSegments批量合并,把所有链路的折线点塞进一个BufferGeometry,每个顶点带一个颜色属性表示利用率。这样几千条链路也只有一个 draw call。
4.5 拾取交互:Raycaster 有代价,别每帧都跑
点选设备的实现,标准做法是Raycaster.intersectObject。但这里有个陷阱:如果对InstancedMesh做射线检测,Three.js 会遍历所有实例,几千个实例时单次检测就要几毫秒。如果在pointermove里做悬浮高亮,鼠标一动就卡。
我的处理是:鼠标移动时不做射线检测,只在click时做一次。悬浮效果改用 GPU 拾取或者干脆不做——实际用起来,运维同事更需要的是“点击后右侧弹出详情面板”,而不是“鼠标划过时有个描边”。
const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); renderer.domElement.addEventListener('click', (e) => { const rect = renderer.domElement.getBoundingClientRect(); pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(pointer, camera); const hits = raycaster.intersectObject(inst); if (!hits.length) return; const idx = hits[0].instanceId; const dev = deviceList[idx]; // 顺序必须与写入时一致 openDetailPanel(dev); flyTo(dev); });instanceId与设备列表的下标对应关系一定要保证不被打乱。我一开始为了省内存,用filter动态过滤可见设备,结果下标全错位了,点 A 弹出 B 的信息。后来改成:设备数组顺序固定不变,通过修改实例矩阵把它移到看不见的位置(比如y = -9999)来实现“隐藏”,instanceId永远可靠。
5. 视觉表达:数据怎么变成看得懂的画面
5.1 信号覆盖与波束,用加法混合就够
通信场景有“信号覆盖范围”的表达需求,很多人第一反应是做体积光、做后处理辉光,其实代价很大。我用的是最朴素的一套:一个半透明的球体或者锥体,配合AdditiveBlending和低透明度,看起来就像光晕。
const coverMat = new THREE.MeshBasicMaterial({ color: 0x2f9bff, transparent: true, opacity: 0.12, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide }); const cover = new THREE.Mesh(new THREE.SphereGeometry(8, 24, 16), coverMat);depthWrite: false是必须的,否则多个半透明球体互相遮挡会出现奇怪的硬边。side: DoubleSide是为了从球体内部看的时候也能看到球壁,相机飞进覆盖范围时不会突然“消失”。
波束动效我一般不写复杂 shader,用一个竖直方向的透明锥体加上scale和opacity的时间函数就够了,成本极低,观感也不差。真正需要写 shader 的场合是“沿着链路流动的光点”,这个用一条线的 UV 偏移配合渐变色贴图实现,比 CPU 逐帧改顶点快一个数量级。
5.2 告警高亮:颜色之外还要有节奏
颜色是最直观的,但只用颜色有个问题——色弱用户和不熟悉配色的人区分不开。所以我的告警视觉是“颜色 + 呼吸节奏 + 图标”三重叠加。
告警级别映射我用固定的一套色板,避免设计稿每次改动:
| 级别 | 颜色值 | 表现方式 | 备注 |
|---|---|---|---|
| 紧急 | 0xff3b30 | 呼吸灯,周期 1.2s,叠加外圈扩散 | 同时置顶告警列表 |
| 重要 | 0xff9500 | 呼吸灯,周期 2.0s | 设备上方悬浮图标 |
| 次要 | 0xffcc00 | 常亮,不呼吸 | |
| 提示 | 0x8e8e93 | 常亮,低亮度 | 只有详情面板可见 |
| 正常 | 0x34c759 | 常亮 | |
| 离线 | 0x48484a | 半透明 | 不可点选,避免误操作 |
呼吸效果不要用setInterval去改材质透明度,多个告警同时存在时会驱动几十个计时器。正确做法是在渲染循环里统一用一个clock的相位计算,按索引错开相位:
const phase = clock.getElapsedTime(); alarms.forEach((a, i) => { const k = (Math.sin(phase * 3 + i * 0.6) + 1) / 2; a.material.opacity = 0.35 + k * 0.55; });5.3 相机控制:飞行不能穿墙,得有点讲究
OrbitControls开箱即用,但有两个参数必须调:maxPolarAngle限制到Math.PI / 2.2左右,防止用户把相机转到地平面以下看到“地底下”的穿帮;enableDamping打开让拖拽有惯性,观感会好很多。
“飞向某设备”的动画,很多人用camera.position.lerp(target, 0.1)在渲染循环里插值,简单但会穿墙——直线插值不判断中间有没有障碍物。我的做法是用TWEEN或者自己写个三次贝塞尔,控制点抬到场景上方,让相机先升空再落下,视觉上像是“俯瞰着飞过去”,既避免了穿墙,也让用户不丢失空间感。
const curve = new THREE.CatmullRomCurve3([ camera.position.clone(), camera.position.clone().setY(camera.position.y + 12), target.clone().setY(target.y + 12), target.clone() ]);顺便说一个实际使用中很受欢迎的小功能:在相机飞行过程中,把目标设备所在的机柜和楼层用线框描边高亮出来。用户的心智是“我要找的东西在那栋楼里、那个柜子里”,光有个设备在发光,位置感其实很弱。
6. 性能优化实录:从 20 帧到 55 帧
6.1 按需渲染,别让 GPU 空转
Three.js 默认是requestAnimationFrame无限循环。如果场景是静态的(没有动画、相机没动),每一帧都在白白渲染。我们的做法是“脏标记 + 按需渲染”:
let needsRender = true; const markDirty = () => { needsRender = true; }; function loop() { requestAnimationFrame(loop); const delta = clock.getDelta(); if (controls.enableDamping) { controls.update(); // update 返回 true 表示相机仍在动 } if (hasAnimations || cameraMoved || needsRender) { renderer.render(scene, camera); needsRender = false; } }大屏展示场景通常是 24 小时挂着的,这个优化带来的不只是帧率,还有功耗和机器温度——之前用一台小主机跑大屏,GPU 满载时风扇噪音很吵,改成按需渲染后基本静音。
6.2 内存释放,这是 Three.js 最容易漏的一笔账
切换楼层的时候,旧楼层的几何体、材质、贴图如果只从scene里remove掉,显存是不会释放的。Three.js 不会自动 GC 这些 GPU 资源,必须手动dispose。
function disposeTree(obj) { obj.traverse((o) => { if (o.geometry) o.geometry.dispose(); if (o.material) { const mats = Array.isArray(o.material) ? o.material : [o.material]; mats.forEach((m) => { Object.keys(m).forEach((k) => { const v = m[k]; if (v && v.isTexture) v.dispose(); }); m.dispose(); }); } }); }这段代码我在每个项目里都会写一遍。漏掉它,来回切换十几次楼层,显存占用会翻好几倍,最后表现为浏览器崩溃或者画面撕裂。排查这类问题时,打开开发者工具的显存监控,看数字只增不减,基本就能确定是资源没释放。
6.3 分辨率与自适应,大屏和笔记本要区别对待
三维场景的resize处理,如果只监听window.resize,有个场景会漏掉:大屏拼接墙的浏览器窗口尺寸在物理上是不变的,但用户可能用 F11 切换全屏,这时触发的是resize,但如果页面嵌在 iframe 里,父窗口变化不会传到子页面。所以我一般会用ResizeObserver监听容器元素,比监听窗口更可靠。
另外,笔记本外接大屏时,像素比差异很大。我的策略是按容器宽度分档:小于 1200px 用pixelRatio = 1,1200 到 2000 用 1.5,超过 2000 用 2。档位切换时给场景加一个短暂的淡入,掩盖重新渲染带来的抖动。
6.4 优化前后的一组实测数据
同一场景、同一台机器(集成显卡的小主机),我记录过一轮优化前后的对比,供参考:
| 优化项 | 优化前 | 优化后 | 主要影响 |
|---|---|---|---|
| 设备渲染方式 | 4800 个独立 Mesh | 1 个 InstancedMesh | draw call 从 4800 降到 1 |
| 链路渲染 | 1200 个 Line 对象 | 1 个合并 LineSegments | draw call 从 1200 降到 1 |
| 阴影 | 开关打开 | 全部关闭 | 帧率提升约 30% |
| 像素比 | 设备原生(3.0) | 夹到 2.0 | 像素量减少 55% |
| 渲染循环 | 无限循环 | 按需渲染 | 空闲时 GPU 占用近 0 |
| 模型压缩 | glTF 原模 | Draco + KTX2 | 首屏资源从 42MB 降到 6MB |
优化后稳定在 55 帧左右,首屏可交互时间从 9 秒降到 2.5 秒左右。这组数据不是绝对的,但比例关系大致可参考。
7. 踩坑速查与几条不上文档的经验
7.1 常见问题速查表
| 现象 | 最可能的原因 | 处理办法 |
|---|---|---|
| 远处设备闪烁、表面撕裂 | 相机 near/far 跨度太大,深度精度不足 | 缩小 far 值,或启用logarithmicDepthBuffer |
| 点选设备总是选中错误的 | InstancedMesh 下标与数据数组错位 | 固定数组顺序,用移出视野代替删除 |
| 半透明覆盖球有硬边 | 材质未关闭depthWrite | 设depthWrite: false,配合排序 |
| 模型加载 404 但路径没错 | 静态服务 MIME 类型未配 | .glb配model/gltf-binary |
| 切换楼层后越来越卡 | 几何体/材质未 dispose | 统一走 dispose 工具函数 |
| 颜色显示偏暗 | 材质基色不是白色,与 instanceColor 相乘 | 材质color设为0xffffff |
| 页面切到后台回来画面停滞 | WebSocket 断连且无兜底 | 加定时全量同步 |
| 大屏上文字糊 | canvas 纹理分辨率不足 | 文字用 HTML 叠加层,不用 canvas 纹理 |
7.2 数据层面的两个真实教训
第一个教训关于坐标系原点。项目初期我们让每个楼栋各自以自己的中心为原点建模,结果楼层之间拼接时出现了几米的错位,原因是建模时用的参考点不一致。后来统一规定:整个园区以一个固定点作为世界原点,所有模型导出时都带上偏移量,前端加载后统一position.set校准。这个规范看起来啰嗦,但它省掉了后期无穷无尽的对接扯皮。
第二个教训关于状态字段的语义。我们一开始用status = 1表示告警,但业务方后来提出还要区分“已确认未恢复”和“未确认”,一个字段塞不下,最后是加了个ack_status。教训是:可视化里的颜色映射依赖状态枚举,这个枚举一定要在项目启动阶段就跟业务方对齐清楚,别等到大屏上线了再改,那时候前端所有颜色映射、图例、筛选项全要跟着动。
7.3 给打算做这套东西的人几句实话
三维可视化项目的成本大头从来不在“把模型画出来”,而在数据治理。我参与过的项目里,模型和代码的工作量大概占四成,剩下六成全是坐标补录、台账对齐、链路关系梳理。如果台账里的机柜 U 位是空的、光缆路径是“暂缺”的,那三维场景做出来就是个漂亮的空壳,运维同事用两次就不会再打开了。
所以我的建议是:先做一个小范围的试点,选一个机房、一层楼,把坐标和链路完整地跑通一遍,让实际使用的人提意见。等这个小闭环跑顺了,再往全园区推。上来就铺全量的项目,我见过不止一个卡在半路——不是技术做不出来,是数据补不齐,需求方也说不清到底要看到什么粒度。
另外,别低估“降级方案”的价值。三维场景在低端设备上跑不动是常态,我通常会给每个页面配一个二维拓扑视图作为降级,检测到帧率持续低于 25 就提示用户切换。这个功能做起来不复杂,但能避免“大屏在领导参观时卡住”这种要命场面。踩过几次坑之后我养成一个习惯:任何三维页面交付前,都拿一台最老的办公笔记本跑一遍,能跑通再谈上线。
最后分享一个交互上的小技巧。设备的详情面板不要做成“点一下弹一个浮层”,而是固定在右侧占三分之一宽度,点选设备时只更新内容。三维场景最怕的就是浮层遮挡视野,用户点完设备想转个角度看,结果被弹窗挡住半个屏幕,体验很糟。固定侧栏加一个可折叠按钮,是我试过最平衡的方案。