☰
ThreeJS 3D机房可视化源码:建模、拾取与性能优化
2026/10/26 13:04:41 网站建设 项目流程

简介:Three.js 3D可视化机房是一套面向Web前端与WebGL开发者的实战型3D应用源码,以第一人称视角在浏览器中构建可漫游的虚拟机房,适合从页面开发进阶到3D交互可视化的中级学习者参考。压缩包共176个文件、约8.54MB,以60个js脚本、15个ts声明、数十张png/jpg贴图为主,另含html入口页、gltf/fbx/3ds模型与7份md文档,代码、素材、说明齐备,可直接运行并二次修改。目前已有6031人次学习,参考价值明确。工程重点演示EffectComposer后期合成与辉光、WebGLRenderer抗锯齿、MaskPass遮罩控制,使亮点物体泛出光晕、模型边缘更平滑、局部区域按需显隐;目录中v1-js与v2-jsm两个版本可对比传统脚本与ES6模块化的组织差异,doc含API与教程文档,便于对照修改、理解渲染管线与交互逻辑。

1. 凌晨告警找不到机柜,3D 机房值不值得做

凌晨两点机房温度告警,值班同事在 2D 拓扑图上翻了五分钟才定位到第三排第七个机柜——这种场景我见过太多次。ThreeJS 3D 可视化机房这套源码要解决的就是这件事:把机柜、服务器 U 位、告警状态和巡检入口放进一个可旋转、可缩放、可点选的三维场景里,让“第几排第几列”变成一眼能认出的空间位置。它是一套 Web 开发源代码,前端用 ThreeJS 渲染,数据层用 JS/AJAX 拉取机房布局,适合做运维可视化、机房资产管理、教学演示的前端同学,也适合想从 2D 组态切到 3D 的开发者。拿到源码后别急着改模型,先按下面的顺序把场景跑通,再替换成自己的机房数据。

2. 搭起可运行的 3D 机房骨架:场景、相机、渲染器与坐标单位

一套 3D 机房源码能不能改得动,第一眼不看模型多漂亮,而看场景骨架是否干净:相机初始位置是否合理、渲染器像素比有没有上限、坐标单位是否统一、数据是不是从接口进来。很多 Demo 在作者机器上跑得流畅,换一台 2K 屏笔记本就开始掉帧,问题往往出在setPixelRatio没限制、阴影贴图开得太大、每台服务器一个独立材质。先把最小渲染循环跑通,再谈机柜和服务器。

2.1 从源码目录到第一帧:最小渲染循环

这套 Web 开发源代码的前端入口通常是一个main.js或room.js,里面做四件事:创建场景、创建透视相机、创建 WebGL 渲染器、启动requestAnimationFrame循环。下面这段是骨架代码,直接决定后续所有模型的坐标基准和交互手感。

import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; const container = document.getElementById('room-canvas'); const scene = new THREE.Scene(); scene.background = new THREE.Color(0x0b1220); scene.fog = new THREE.Fog(0x0b1220, 30, 90); // 远处机柜淡出,减少视觉噪音 const camera = new THREE.PerspectiveCamera( 45, // 机房场景别用超广角,45° 更接近人眼,机柜不易变形 container.clientWidth / container.clientHeight, 0.1, 200 ); camera.position.set(12, 9, 16); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // 高 DPI 手机限到 2,避免像素爆炸 renderer.setSize(container.clientWidth, container.clientHeight); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; container.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0, 1.2, 0); // 视线落在机柜中部,不是地面 controls.enableDamping = true; controls.dampingFactor = 0.08; controls.maxPolarAngle = Math.PI / 2 - 0.05; // 禁止钻到地面以下 controls.minDistance = 3; controls.maxDistance = 40; const clock = new THREE.Clock(); function tick() { const dt = clock.getDelta(); controls.update(dt); // 开了 damping 就必须每帧 update renderer.render(scene, camera); requestAnimationFrame(tick); } tick();

逻辑说明:PerspectiveCamera的视场角用 45°,机房这种以长方体为主的场景如果开到 75°,边缘机柜会被拉斜,点击拾取时手感也会飘。camera.position和controls.target要一起调,只改相机位置会让轨道中心偏到墙外。renderer.setPixelRatio是移动端发热的第一道闸,限制到 2 比默认跟随设备像素比稳得多。controls.update(dt)不能省,否则阻尼失效,松手就硬停。

参数说明:near=0.1、far=200按机房 30 米以内的尺度够用;fog的近远距离要跟机房尺寸匹配,写死 30/90 只适合十几米长的房间,机房更大就要按包围盒计算。响应式部分别只监听resize改渲染器尺寸,还要同步相机宽高比和 CSS2D 标签层,否则窗口一拉,标签和模型会错位。

function onResize() { const w = container.clientWidth; const h = container.clientHeight; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); labelRenderer.setSize(w, h); // 标签层和 WebGL 层必须同尺寸 } window.addEventListener('resize', onResize);

这段代码看着简单,却是后面所有坑的源头:标签层没同步、容器宽高取到 0、resize事件触发太频繁导致卡顿,都会在这里埋雷。容器宽高建议用ResizeObserver监听父元素,不要只监听window,后台管理系统的侧边栏一收起来,canvas 宽度就变了。

2.2 机房坐标系统与单位换算:1U=44.45mm 怎么落进 ThreeJS

3D 机房最常见的翻车不是模型丑,而是比例乱:机柜高 2 米、服务器厚 4 厘米、U 位间距却按 1 个单位摆,最后服务器像飘在半空。ThreeJS 本身没有单位,但团队必须约定一个:我一般用“米”作为场景单位,地面y=0,机柜底部贴地,服务器按1U = 0.04445m换算。这样相机距离、雾效、阴影范围都能用真实尺度描述,换机房时只改数据不改代码。

对象真实尺寸ThreeJS 单位(米)坐标基准
标准机柜宽 600mm、深 1200mm、高 2000mm0.6 × 1.2 × 2.0底部中心为原点
1U 服务器高 44.45mm0.04445所在 U 位底部
机房通道宽 1200mm1.2两排机柜之间
相机初始高度约 1.6m 人眼1.6~2.0看向机柜中部

机柜位置不要直接在代码里写mesh.position.set(3.2, 0, -1.8),而是从布局数据里读row、col、x、z、rotation。常见的做法是把机房画成网格:每排机柜沿 X 轴排布,排与排之间沿 Z 轴留 1.2 米通道。下面这个工具函数把“第几排第几列”换算成世界坐标,后面所有机柜都走它,避免一处偏移处处偏移。

const CABINET_W = 0.6; const AISLE_W = 1.2; function cabinetWorldPosition(row, col, rowGap = 1.6) { return { x: col * (CABINET_W + 0.05), // 机柜之间留 5cm 缝,避免视觉粘连 z: row * (1.2 + AISLE_W), // 机柜深 1.2m + 通道 1.2m rotationY: 0 }; }

逻辑说明:col * (CABINET_W + 0.05)让同一排机柜等距排开,缝隙太小阴影会糊成一片,太大又不像真实机房。rowGap参数留给“背靠背”布局,有些机房两排机柜共用一条冷通道,行距就不是 2.4 米。旋转角rotationY不要省,机房经常有 L 形布局,机柜朝外和朝内是两套朝向。

参数说明:如果后端给的坐标单位是毫米,进场景前统一除以 1000,别在建模函数里临时乘系数。我见过一个项目一半模型用米、一半用厘米,最后相机拉远看还行,拉近点服务器直接穿出机柜。U 位换算也一样,uStart从 1 开始还是从 0 开始要在数据层定死,前端只做uStart * U,不要每个模型自己猜。

2.3 用 AJAX 拉取机房布局数据并驱动场景

机房可视化不是手工摆模型,而是数据驱动。这套 JS/AJAX 源代码的数据层一般用fetch拉一个room-layout.json,里面包含机房尺寸、机柜数组、每个机柜的服务器列表。先别接真实接口,用静态 JSON 把场景跑通,再接后端。这样模型问题和数据问题能分开排查。

async function loadRoomLayout(url) { const res = await fetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' // 机房状态变化快,调试期别让浏览器缓存旧布局 }); if (!res.ok) { throw new Error(`机房布局加载失败: ${res.status} ${res.statusText}`); } const data = await res.json(); if (!Array.isArray(data.cabinets)) { throw new Error('布局数据缺少 cabinets 数组'); } return data; } loadRoomLayout('/api/room/layout') .then((layout) => buildRoom(layout)) .catch((err) => { console.error(err); showRoomError('机房布局加载失败,请检查接口或 JSON 格式'); });

逻辑说明:cache: 'no-store'只在调试期建议加,生产环境按接口缓存策略来。res.ok判断不能省,很多“场景空白”不是 ThreeJS 的问题,而是接口返回 404 后res.json()抛错被吞了。cabinets数组校验是防止后端改字段名,前端静默渲染出一个空房间。

参数说明:布局 JSON 里建议至少包含room.width、room.depth、cabinets[].id、cabinets[].row、cabinets[].col、cabinets[].servers[].uStart、servers[].uCount、servers[].state。state用normal / warning / critical / offline四档就够,别把后端所有状态码直接塞进颜色映射,否则前端会变成状态字典维护现场。接口返回慢时先渲染地面和机柜壳,服务器数据后到再补,不要让用户盯着黑屏等 AJAX。

3. 机柜与服务器建模:用 BoxGeometry 拼出可维护的 U 位结构

机房 3D 场景里 90% 的物体是长方体,不需要上来就 glTF 精模。机柜用BoxGeometry拼壳、前门、立柱,服务器用薄盒子按 U 位叠放,既能保证帧率,也方便后面按userData做拾取。关键是参数化:宽、深、高、U 数、颜色都从配置进,不要把 0.6、1.2、2.0 写死在十几个地方。

3.1 机柜参数化:宽深高、前后门与立柱

一个能看的机柜至少三层:外壳、前门内凹框、顶部标识。外壳用深灰金属材质,前门用半透明深色玻璃或网格材质,这样里面的服务器能透出来。别把前门做成完全不透明,否则点开机柜只能看到一块铁皮。下面这个函数返回一个Group,原点在机柜底部中心,后续摆放只改group.position。

function createCabinet(cfg = {}) { const { width = 0.6, depth = 1.2, height = 2.0, bodyColor = 0x252b33, doorColor = 0x11161d } = cfg; const group = new THREE.Group(); group.name = 'cabinet'; // 外壳:略小于真实尺寸,给门和缝隙留空间 const body = new THREE.Mesh( new THREE.BoxGeometry(width, height, depth), new THREE.MeshStandardMaterial({ color: bodyColor, metalness: 0.55, roughness: 0.45 }) ); body.position.y = height / 2; body.castShadow = true; body.receiveShadow = true; group.add(body); // 前门:半透明,能看见内部服务器状态灯 const door = new THREE.Mesh( new THREE.BoxGeometry(width * 0.92, height * 0.94, 0.02), new THREE.MeshStandardMaterial({ color: doorColor, metalness: 0.2, roughness: 0.75, transparent: true, opacity: 0.72 }) ); door.position.set(0, height / 2, depth / 2 + 0.012); group.add(door); // 顶部横梁,方便视觉上区分机柜朝向 const topBar = new THREE.Mesh( new THREE.BoxGeometry(width * 0.96, 0.04, depth * 0.96), new THREE.MeshStandardMaterial({ color: 0x3a4450, metalness: 0.6, roughness: 0.4 }) ); topBar.position.y = height - 0.02; group.add(topBar); group.userData = { type: 'cabinet', id: cfg.id || '' }; return group; }

逻辑说明:外壳body的position.y = height / 2是把“底部中心”原点抬到几何中心,因为BoxGeometry默认以自身中心为原点。前门往外偏移depth / 2 + 0.012,避免和外壳共面产生 Z-fighting,那个 0.012 是血泪经验,贴太近会出现闪烁条纹。castShadow和receiveShadow按需开,机柜数量多的时候阴影开销很大,可以只让外壳投影,门和横梁不投影。

参数说明:metalness不要给到 1,没有环境贴图时金属度越高越黑,0.4~0.6 配一盏方向光就够了。opacity0.72 是能看见内部又不至于完全透明,低于 0.5 机柜会显得像塑料壳。group.userData.id必须写,后面射线拾取到子网格时要向上找祖先拿机柜 ID,没有这个字段就只能靠name字符串匹配,改一次名全崩。

3.2 服务器 U 位排布与状态色映射

服务器不是按“第几台”摆,而是按“从第几 U 开始、占几 U”摆。1U 服务器厚 0.04445 米,2U 就是 0.0889 米,但模型不能严丝合缝顶满,否则相邻服务器会 Z-fighting。我一般让实际高度乘 0.92,留一点缝。颜色按状态映射,正常绿、告警黄、严重红、离线灰,材质尽量复用,别每台服务器 new 一个MeshStandardMaterial。

const U = 0.04445; const STATE_COLOR = { normal: 0x2ecc71, warning: 0xf1c40f, critical: 0xe74c3c, offline: 0x7f8c8d }; const serverMaterialCache = new Map(); function getServerMaterial(state) { if (!serverMaterialCache.has(state)) { serverMaterialCache.set( state, new THREE.MeshStandardMaterial({ color: STATE_COLOR[state] || STATE_COLOR.offline, metalness: 0.1, roughness: 0.6, emissive: STATE_COLOR[state] || STATE_COLOR.offline, emissiveIntensity: state === 'critical' ? 0.55 : 0.15 }) ); } return serverMaterialCache.get(state); } function addServerToCabinet(cabinet, server, cabinetDepth = 1.2) { const { id, uStart, uCount = 1, state = 'offline' } = server; const h = uCount * U * 0.92; const mesh = new THREE.Mesh( new THREE.BoxGeometry(0.54, h, cabinetDepth * 0.78), getServerMaterial(state) ); // uStart 从 1 开始,机柜底部留 0.08m 底座 mesh.position.set(0, 0.08 + (uStart - 1) * U + h / 2, 0.06); mesh.castShadow = false; // 服务器在柜内,阴影收益低,关掉省性能 mesh.userData = { type: 'server', id, uStart, uCount, state, cabinetId: cabinet.userData.id }; cabinet.add(mesh); return mesh; }

逻辑说明:h = uCount * U * 0.92留缝,position.y先加底座 0.08 米,再加(uStart - 1) * U,最后加半个自身高度,这样服务器底部正好落在 U 位起点。cabinetDepth * 0.78让服务器比机柜浅一点,前门关上时不会穿模。材质缓存按状态复用,几百台服务器只产生四个材质,GPU 上传和 draw call 都会少很多。

参数说明:uStart从 1 开始还是从 0 开始必须跟后端对齐,我一般强制从 1 开始,0 留给“未知”。emissiveIntensity严重状态给 0.55,正常给 0.15,太高会让红色糊成一片,太低又看不出告警。服务器castShadow=false是性能取舍,如果机房只有十几台服务器可以打开,超过两百台就关掉,用发光色表达状态更划算。

3.3 数据驱动建机房:一份 JSON 铺满整个场景

机柜和服务器函数写好之后,建场景就变成循环:先铺地面和墙,再按cabinets数组摆机柜,每个机柜里按servers数组摆服务器。下面这段是buildRoom的核心,重点看错误隔离:单个机柜数据坏了不能让整个机房空白。

function buildRoom(layout) { const room = new THREE.Group(); room.name = 'room-root'; const floor = new THREE.Mesh( new THREE.PlaneGeometry(layout.room.width, layout.room.depth), new THREE.MeshStandardMaterial({ color: 0x1a2029, roughness: 0.9, metalness: 0.05 }) ); floor.rotation.x = -Math.PI / 2; floor.receiveShadow = true; room.add(floor); layout.cabinets.forEach((cabinetData) => { try { const cabinet = createCabinet({ id: cabinetData.id, width: cabinetData.width || 0.6, depth: cabinetData.depth || 1.2, height: cabinetData.height || 2.0 }); cabinet.position.set(cabinetData.x, 0, cabinetData.z); cabinet.rotation.y = cabinetData.rotationY || 0; cabinet.userData.row = cabinetData.row; cabinet.userData.col = cabinetData.col; (cabinetData.servers || []).forEach((server) => { addServerToCabinet(cabinet, server, cabinetData.depth || 1.2); }); room.add(cabinet); } catch (err) { console.warn(`机柜 ${cabinetData.id} 构建失败`, err); } }); scene.add(room); return room; }

逻辑说明:地面单独铺,不要每个机柜带一块地板,否则重叠面会闪烁。cabinet.position.set(x, 0, z)的y固定 0,因为机柜组原点已经在底部,如果模型原点在中心,这里就要加height / 2,原点约定必须在建模函数里写死。try/catch包住单个机柜,某个servers字段不是数组时只跳过这个柜,不影响整排。

参数说明:layout.room.width/depth用来定地面大小,也建议用来算相机maxDistance和雾效距离,别让用户能缩到墙外。rotationY传给cabinet.rotation.y,L 形机房靠它转向。cabinetData.x/z如果是毫米,在这里统一除以 1000,不要留到服务器层级再换,越晚换越乱。

4. 交互与信息呈现:Raycaster 拾取、CSS2D 标签与侧边详情面板

3D 机房如果只能转不能点,价值少一半。运维人员要的是“点一下机柜,右侧弹出这台柜的 U 位占用和告警”;“鼠标移上去,机柜高亮,标签显示名称”。ThreeJS 做这件事靠Raycaster拾取 +CSS2DRenderer标签 + 普通 HTML 侧栏。三者分工要清楚:射线负责选中对象,CSS2D 负责空间锚定文字,侧栏负责复杂表单和列表,别把所有信息都塞进 3D 纹理里。

4.1 射线拾取与悬停高亮:别改共享材质

射线拾取最常见的错误是拿鼠标坐标直接当 NDC 坐标用。Raycaster.setFromCamera要的是-1~1的归一化设备坐标,必须用 canvas 的getBoundingClientRect()换算,而且要考虑 canvas 在页面中的偏移。另一个坑是intersectObjects默认不递归,机柜是Group,直接传机柜组进去点不到里面的服务器。

const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); let hovered = null; let originalEmissive = null; function updatePointer(event) { const rect = renderer.domElement.getBoundingClientRect(); pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; } function pickAtPointer() { raycaster.setFromCamera(pointer, camera); const roots = scene.getObjectByName('room-root'); const hits = raycaster.intersectObjects(roots.children, true); // 必须递归 for (const hit of hits) { let node = hit.object; while (node && !node.userData?.type) node = node.parent; if (node?.userData?.type === 'cabinet' || node?.userData?.type === 'server') { return node; } } return null; } function onPointerMove(event) { updatePointer(event); const target = pickAtPointer(); if (target === hovered) return; if (hovered) { hovered.traverse((child) => { if (child.isMesh && child.material?.emissive) { child.material.emissiveIntensity = child.userData._oldEmissive ?? 0.15; } }); hovered = null; } if (target) { hovered = target; target.traverse((child) => { if (child.isMesh && child.material?.emissive) { child.userData._oldEmissive = child.material.emissiveIntensity; child.material.emissiveIntensity = 0.8; } }); renderer.domElement.style.cursor = 'pointer'; } else { renderer.domElement.style.cursor = 'grab'; } }

逻辑说明:pointer.y前面有负号,因为浏览器 Y 轴向下、NDC Y 轴向上,漏掉负号会出现“鼠标在上、选中在下”。while (node && !node.userData?.type)向上找带业务类型的祖先,点到服务器子网格也能归到服务器或机柜。高亮时保存原emissiveIntensity,不要直接改material.color,否则材质是共享的,所有同状态服务器一起变色。

参数说明:roots.children只传机房根节点,不要传整个scene,场景里还有灯光、地面、辅助线,全量递归会拖慢每次pointermove。悬停事件建议加 50ms 节流,高频pointermove加射线检测在低端设备上会卡。intersectObjects第二个参数true不能省,这是新手最常见的“点了没反应”。

4.2 CSS2DRenderer 标签:文字别烘进纹理

机柜名称、IP 段、温度这些信息如果烘成纹理贴到模型上,远处会糊、近处锯齿、改一个字要重新生成贴图。用CSS2DRenderer把 HTML 标签锚在 3D 坐标上,字体清晰、可选中、可加 CSS 动画。代价是 DOM 数量,几百个标签同时开着会拖慢页面,所以标签要按距离和视角做显隐。

import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js'; const labelRenderer = new CSS2DRenderer(); labelRenderer.setSize(container.clientWidth, container.clientHeight); labelRenderer.domElement.style.position = 'absolute'; labelRenderer.domElement.style.top = '0'; labelRenderer.domElement.style.left = '0'; labelRenderer.domElement.style.pointerEvents = 'none'; // 关键:别挡住 canvas 的鼠标事件 container.appendChild(labelRenderer.domElement); function createCabinetLabel(cabinet, text) { const div = document.createElement('div'); div.className = 'cabinet-label'; div.textContent = text; const label = new CSS2DObject(div); label.position.set(0, 2.15, 0); // 机柜顶部上方 label.center.set(0.5, 1); cabinet.add(label); return label; } // 每帧渲染标签层 function tick() { controls.update(); renderer.render(scene, camera); labelRenderer.render(scene, camera); requestAnimationFrame(tick); }

逻辑说明:pointerEvents = 'none'必须加,否则标签层是一整张透明 DOM,鼠标事件全被它截走,射线拾取永远点不到模型。标签挂在机柜Group下,机柜移动标签跟着走,不用每帧手算屏幕坐标。label.center.set(0.5, 1)让标签底部对齐锚点,文字在机柜正上方而不是中心穿模。

参数说明:标签位置y=2.15是按 2 米机柜留 15cm,机柜加高就要改。标签显隐我一般按相机距离做阈值:超过 25 米隐藏,只留选中机柜的标签;同屏标签超过 80 个就按视野裁剪。labelRenderer.domElement的z-index要低于侧栏、高于 canvas,否则标签会盖住弹窗。

4.3 点击弹窗与 AJAX 详情接口:把重信息放回 HTML

点击机柜后不建议在 3D 里画详情,直接用右侧固定面板。射线拾取拿到cabinetId或serverId,发一个 AJAX 请求拉详情,渲染成表格。这样详情字段增删不用动 ThreeJS 代码,前端和后端都好维护。下面这段带加载态和请求取消,避免快速连点导致旧响应覆盖新面板。

let detailController = null; async function openDetailPanel(type, id) { const panel = document.getElementById('detail-panel'); panel.classList.add('is-open'); panel.innerHTML = '<div class="loading">加载中...</div>'; if (detailController) detailController.abort(); detailController = new AbortController(); try { const res = await fetch(`/api/room/${type}/${encodeURIComponent(id)}`, { signal: detailController.signal, headers: { Accept: 'application/json' } }); if (!res.ok) throw new Error(`详情接口 ${res.status}`); const detail = await res.json(); renderDetail(panel, detail); } catch (err) { if (err.name !== 'AbortError') { panel.innerHTML = '<div class="error">详情加载失败,请重试</div>'; console.error(err); } } } renderer.domElement.addEventListener('click', () => { const target = pickAtPointer(); if (!target) return; const { type, id } = target.userData; if (type && id) openDetailPanel(type, id); });

逻辑说明:AbortController用来取消上一次请求,快速点三个机柜时只保留最后一个响应,否则面板会来回跳。encodeURIComponent防止 ID 里带斜杠或中文导致路径错。点击事件绑在renderer.domElement上,不要绑在window,否则点侧栏也会触发拾取。

参数说明:详情接口返回什么就渲染什么,前端不要写死字段顺序。侧栏宽度建议 360~420px,太窄表格挤,太宽挡场景。加载态和错误态必须有,机房接口偶尔超时,用户点了没反应会以为 3D 卡死。如果详情里还要嵌实时温度曲线,建议单独用轻量图表库,不要把图表也画进 ThreeJS。

5. 避坑与排查:3D 机房项目里最容易翻车的五件事

3D 机房源码能跑起来只是开始,真正耗时间的是“看起来差不多、点起来不对”的问题。下面五条是我在类似项目里反复遇到的,每条按“现象 → 原因 → 解决”拆开。排查顺序建议从渲染器、坐标、交互、资源释放、移动端性能依次走,别一上来就怀疑 ThreeJS 版本。

5.1 现象:场景全黑,机柜像剪影

现象:页面打开后 canvas 一片黑,只能看到机柜轮廓,服务器颜色全灰,调高颜色值也没用。

原因:最常见是灯光没加或强度太低。MeshStandardMaterial不是自发光材质,没有环境光和方向光时,metalness越高越黑;机房源码里如果只加了一盏AmbientLight,金属外壳会把环境光“吃”掉。第二个原因是渲染器色彩空间没设,老代码默认线性输出,深色材质会发闷。第三个原因是相机在机柜内部或near/far设错,整个场景被裁掉。

解决:先加HemisphereLight提供基础环境光,再加一盏DirectionalLight打阴影,强度按场景尺度调。机柜金属度降到 0.5 左右,没有环境贴图时不要给 0.9。渲染器颜色空间按当前 ThreeJS 版本设置,别抄旧教程的outputEncoding。排查时临时把背景设成亮灰色、材质换成MeshBasicMaterial,如果能看到模型,说明几何没问题,问题在光照和材质。

const hemi = new THREE.HemisphereLight(0xbfd4ff, 0x1a2029, 0.9); scene.add(hemi); const dir = new THREE.DirectionalLight(0xffffff, 1.6); dir.position.set(8, 14, 10); dir.castShadow = true; dir.shadow.mapSize.set(1024, 1024); dir.shadow.camera.left = -15; dir.shadow.camera.right = 15; dir.shadow.camera.top = 15; dir.shadow.camera.bottom = -15; scene.add(dir);

shadow.mapSize不要一上来 4096,机房这种大平面 1024 配 PCFSoft 够用,阴影相机范围要包住机房,否则边缘机柜没影子。

5.2 现象:机柜整体偏移半米,服务器飘在半空

现象:机柜看起来贴地了,但点击高亮框偏到旁边;或者服务器底部和 U 位刻度对不上,越往上偏得越多。

原因:模型原点不统一。BoxGeometry默认中心在几何中心,有人建机柜时把position.y设成 0,结果机柜一半埋地下;有人又额外加了height / 2,导致整体抬高。服务器 U 位换算时uStart从 0 开始还是从 1 开始没对齐,每台差 1U,叠到 40U 就偏出机柜。

解决:定死一条约定——所有机柜组原点在底部中心,所有服务器position.y从机柜底部往上算,uStart从 1 开始。建一个debugAxis辅助线,在机柜底部画一个十字,肉眼确认原点。U 位换算只允许在一个函数里出现,别在建模、拾取、标签三处各写一遍。

5.3 现象:鼠标点机柜没反应,或者点 A 弹 B

现象:鼠标移到机柜上不变手型,点击不弹面板;或者点第一排机柜,弹出第三排的详情。

原因:三个高频问题。第一,CSS2D 标签层没设pointer-events: none,整层 DOM 把 canvas 事件挡住了。第二,intersectObjects没传递归参数true,机柜是Group,射线只打组不打子网格。第三,鼠标坐标没按 canvas 的getBoundingClientRect()换算,页面有侧边栏或滚动时,NDC 坐标整体偏移,点 A 命中 B。

解决:先检查标签层 CSS,再确认射线递归,最后打印pointer.x/y看范围是否在-1~1。点击事件里加一个临时console.log(hit.object.name),看命中的是不是预期网格。如果用了OrbitControls,拖拽结束也会触发 click,建议记录pointerdown和pointerup的位移,超过 5px 就不当点击处理。

5.4 现象:切换楼层几次后越来越卡,最后浏览器崩

现象:第一次进机房很流畅,切换到第二层、第三层再切回来,帧率明显下降,任务管理器里 GPU 内存持续上涨。

原因:ThreeJS 的几何体、材质、纹理不会自动释放。每次buildRoom都 new 一批BoxGeometry和MeshStandardMaterial,旧场景只是从scene里remove,GPU 资源还在。requestAnimationFrame如果开了多个循环,每切一次多一个,帧率直接腰斩。resize、pointermove事件重复绑定也会累积。

解决:封装disposeObject3D(root),递归调用geometry.dispose()、material.dispose()、texture.dispose(),再从场景移除。全局只保留一个requestAnimationFrame循环,切换楼层只换数据不换循环。事件绑定用AbortController或具名函数,卸载时removeEventListener。

function disposeObject3D(root) { root.traverse((obj) => { if (obj.isMesh) { obj.geometry?.dispose(); const materials = Array.isArray(obj.material) ? obj.material : [obj.material]; materials.forEach((mat) => { Object.values(mat).forEach((v) => { if (v && v.isTexture) v.dispose(); }); mat.dispose(); }); } }); root.parent?.remove(root); }

材质如果放在缓存Map里,释放时要清缓存,否则下一次建场景又拿到已销毁的材质,表现为模型变黑或直接报错。

5.5 现象:移动端发烫、拖拽卡顿,帧率掉到 20

现象:桌面浏览器 60 帧,同一页面在平板或手机上拖拽明显拖影,机身发热,标签越多越卡。

原因:setPixelRatio跟随设备像素比,高分屏手机上实际渲染像素是 CSS 像素的 3 倍;阴影贴图 2048 起步;每台服务器独立材质、独立几何体,draw call 上千;CSS2D 标签几百个同时渲染,DOM 重排拖垮主线程。

解决:像素比限到 2 甚至 1.5,阴影贴图降到 1024,服务器材质按状态缓存,同规格服务器用InstancedMesh合批。标签按相机距离和视野裁剪,超过阈值直接visible = false。移动端默认关阴影,只保留环境光和方向光,用发光色表达状态,视觉差异不大,帧率能回来一截。

6. 把帧率稳住:InstancedMesh 合批、LOD 与上线前的性能验收

机房场景一旦超过两百台服务器,逐台Mesh的 draw call 就会成为瓶颈。我的做法是把同规格、同状态的服务器合成InstancedMesh,用矩阵写位置,用setColorAt写状态色,材质只保留一份。机柜本身数量通常几十到一百多,可以保留独立Group方便拾取,远处再用THREE.LOD换低模。验收时别只看“感觉流畅”,打开renderer.info.render.calls和triangles,记录切换楼层前后的数值,再拿performance.now()统计连续 120 帧的平均帧时间,超过 16.7ms 就说明没到 60 帧。

const MAX_SERVERS = 2000; const geo = new THREE.BoxGeometry(0.54, 0.04, 0.9); const mat = new THREE.MeshStandardMaterial({ metalness: 0.1, roughness: 0.6 }); const instanced = new THREE.InstancedMesh(geo, mat, MAX_SERVERS); instanced.instanceMatrix.setUsage(THREE.DynamicDrawUsage); const dummy = new THREE.Object3D(); const color = new THREE.Color(); let index = 0; function pushServerInstance(x, y, z, stateColor) { dummy.position.set(x, y, z); dummy.updateMatrix(); instanced.setMatrixAt(index, dummy.matrix); instanced.setColorAt(index, color.setHex(stateColor)); index++; } instanced.count = index; instanced.instanceMatrix.needsUpdate = true; instanced.instanceColor.needsUpdate = true; scene.add(instanced);

这段代码的关键参数是MAX_SERVERS要按机房最大容量预留,count按实际写入数量设置,不然会渲染一堆零矩阵实例在原点。instanceColor需要材质支持顶点颜色,状态色才能生效。拾取时InstancedMesh的hit.instanceId能拿到实例索引,再映射回服务器 ID,不要再用逐台Mesh的userData。

验收项目标值查看方式
draw calls小于 200renderer.info.render.calls
三角面数小于 50 万renderer.info.render.triangles
平均帧时间小于 16.7ms连续 120 帧performance.now()
GPU 内存切换楼层后不持续上涨浏览器性能面板对比

上线前我固定跑三遍:空场景、单机柜、全量数据,每遍都切一次楼层再切回来,看renderer.info是否回到基线。从那以后我每次改完机房模型都强制走一遍这个流程,不再靠“看起来不卡”判断。这套源码拿到后,建议先跑最小场景,再把机柜数据替换成自己的,最后才动材质和光照。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询