工业3D场景交互设计:从拾取、高亮到漫游的完整实现方案
面向前端/3D可视化开发者,用 Three.js 实现工业数字孪生场景中的核心交互能力:对象拾取、高亮选中、信息面板联动、相机漫游、剖切分析等。
工业场景交互的特殊性
做工业 3D 可视化和做电商 3D 展示、游戏完全不同:
-
对象密集:一个车间几百台设备,挨得很近,拾取精度要求高
-
层级复杂:工厂→车间→产线→工位→设备→零部件,需要层级化交互
-
数据联动:点击设备不只是高亮,还要弹出实时数据面板
-
性能敏感:交互不能卡顿,大场景下帧率不能掉
-
操作习惯:工厂管理人员不是游戏玩家,交互要直观简单
一、对象拾取
基础方案:Raycaster
Three.js 的 Raycaster 是最常用的拾取方案,从鼠标位置发射一条射线,检测与 3D 对象的交点。
class ObjectPicker {
constructor(camera, scene, domElement) {
this.camera = camera;
this.scene = scene;
this.domElement = domElement;
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.pickableObjects = []; // 可拾取对象列表
domElement.addEventListener('click', this.on_click.bind(this));
domElement.addEventListener('mousemove', this.on_hover.bind(this));
}
updatePickable(objects) {
this.pickableObjects = objects;
}
getMousePosition(event) {
const rect = this.domElement.getBoundingClientRect();
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
}
pick() {
this.raycaster.setFromCamera(this.mouse, this.camera);
// 递归检测子对象
const intersects = this.raycaster.intersectObjects(
this.pickableObjects, true
);
if (intersects.length > 0) {
// 向上查找带 userData.id 的祖先对象
let obj = intersects[0].object;
while (obj && !obj.userData.deviceId) {
obj = obj.parent;
}
return obj ? obj.userData : null;
}
return null;
}
on_click(event) {
this.getMousePosition(event);
const picked = this.pick();
if (picked) {
this.onSelect(picked);
}
}
on_hover(event) {
this.getMousePosition(event);
const picked = this.pick();
if (picked) {
this.domElement.style.cursor = 'pointer';
this.onHover(picked);
} else {
this.domElement.style.cursor = 'default';
}
}
onSelect(data) { /* 子类实现 */ }
onHover(data) { /* 子类实现 */ }
}
性能优化:GPU 拾取
当场景对象超过 1000 个时,Raycaster 的 CPU 计算会成为瓶颈。这时可以用 GPU 拾取(GPU Picking):
class GPUPicker {
constructor(renderer, scene, camera) {
this.renderer = renderer;
this.scene = scene;
this.camera = camera;
// 创建拾取用的离屏渲染目标
this.pickTexture = new THREE.WebGLRenderTarget(1, 1);
this.pickScene = new THREE.Scene();
// 为每个对象分配唯一颜色ID
this.colorToObj = new Map();
this.nextId = 1;
}
registerObject(mesh) {
const id = this.nextId++;
const r = (id >> 16) & 0xff;
const g = (id >> 8) & 0xff;
const b = id & 0xff;
// 创建拾取用的材质(纯色)
const pickMaterial = new THREE.MeshBasicMaterial({
color: new THREE.Color(r / 255, g / 255, b / 255)
});
const pickMesh = mesh.clone();
pickMesh.material = pickMaterial;
this.pickScene.add(pickMesh);
this.colorToObj.set(`${r},${g},${b}`, mesh);
return id;
}
pick(mouseX, mouseY) {
// 设置1x1像素的渲染区域,只渲染鼠标位置的像素
this.renderer.setRenderTarget(this.pickTexture);
this.renderer.setScissor(mouseX, mouseY, 1, 1);
this.renderer.setScissorTest(true);
this.renderer.render(this.pickScene, this.camera);
// 读取像素颜色
const pixel = new Uint8Array(4);
this.renderer.readRenderTargetPixels(
this.pickTexture, mouseX, mouseY, 1, 1, pixel
);
this.renderer.setRenderTarget(null);
this.renderer.setScissorTest(false);
const key = `${pixel[0]},${pixel[1]},${pixel[2]}`;
return this.colorToObj.get(key) || null;
}
}
GPU 拾取的优势:不论场景有多少对象,拾取只需渲染 1 个像素,性能恒定在 O(1)。代价是需要额外的离屏渲染通道和内存。
二、高亮选中效果
方案1:Outline 轮廓高亮
工业场景中最常用的高亮方式——给选中对象加一圈发光轮廓。
class OutlineEffect {
constructor(renderer, scene, camera) {
this.renderer = renderer;
this.scene = scene;
this.camera = camera;
// 后处理composer
this.composer = new EffectComposer(renderer);
// 正常渲染pass
const renderPass = new RenderPass(scene, camera);
this.composer.addPass(renderPass);
// 轮廓pass
this.outlinePass = new OutlinePass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
scene, camera
);
this.outlinePass.edgeStrength = 5.0;
this.outlinePass.edgeGlow = 0.8;
this.outlinePass.edgeThickness = 2.0;
this.outlinePass.visibleEdgeColor.set('#ff8800');
this.outlinePass.hiddenEdgeColor.set('#aa4400');
this.composer.addPass(this.outlinePass);
// 输出pass
const outputPass = new OutputPass();
this.composer.addPass(outputPass);
this.selectedObjects = [];
}
select(objects) {
this.selectedObjects = objects;
this.outlinePass.selectedObjects = objects;
}
clear() {
this.selectedObjects = [];
this.outlinePass.selectedObjects = [];
}
render() {
this.composer.render();
}
}
方案2:状态颜色切换
更轻量的方案——直接改变对象材质颜色表示不同状态:
class DeviceStateManager {
constructor() {
this.colors = {
running: 0x00aa44, // 绿色:运行中
idle: 0x4488cc, // 蓝色:空闲
error: 0xcc3333, // 红色:故障
selected: 0xff8800, // 橙色:选中
highlight: 0xffff00, // 黄色:悬停
};
this.originalMaterials = new Map();
}
setDeviceState(mesh, state) {
// 保存原始材质
if (!this.originalMaterials.has(mesh.uuid)) {
this.originalMaterials.set(mesh.uuid, mesh.material.clone());
}
const color = this.colors[state];
if (mesh.material.color) {
mesh.material.color.setHex(color);
mesh.material.needsUpdate = true;
}
}
restore(mesh) {
const original = this.originalMaterials.get(mesh.uuid);
if (original) {
mesh.material.copy(original);
mesh.material.needsUpdate = true;
}
}
}
方案3:闪烁动画
报警场景下需要吸引注意力,用闪烁效果:
class BlinkAnimator {
constructor() {
this.blinkingObjects = [];
}
startBlink(mesh, color = 0xff0000, interval = 500) {
const originalColor = mesh.material.color.clone();
const blinkColor = new THREE.Color(color);
this.blinkingObjects.push({
mesh,
originalColor,
blinkColor,
interval,
lastToggle: 0,
isBlinking: true
});
}
stopBlink(mesh) {
const item = this.blinkingObjects.find(o => o.mesh === mesh);
if (item) {
mesh.material.color.copy(item.originalColor);
item.isBlinking = false;
}
}
update(deltaTime) {
const now = performance.now();
this.blinkingObjects.forEach(item => {
if (!item.isBlinking) return;
if (now - item.lastToggle > item.interval) {
// 切换颜色
if (mesh.material.color.getHex() === item.blinkColor.getHex()) {
mesh.material.color.copy(item.originalColor);
} else {
mesh.material.color.copy(item.blinkColor);
}
item.lastToggle = now;
}
});
}
}
三、信息面板联动
点击 3D 对象后弹出信息面板,是数字孪生大屏的标配交互。
class InfoPanel {
constructor(container) {
this.panel = document.createElement('div');
this.panel.className = 'twin-info-panel';
this.panel.style.cssText = `
position: absolute;
background: rgba(10, 20, 40, 0.92);
border: 1px solid #00aaff;
border-radius: 8px;
padding: 16px;
color: #fff;
font-size: 14px;
pointer-events: auto;
min-width: 240px;
display: none;
z-index: 100;
box-shadow: 0 4px 20px rgba(0, 170, 255, 0.3);
`;
container.appendChild(this.panel);
this.currentDevice = null;
}
show(deviceData, screenX, screenY) {
this.currentDevice = deviceData;
// 构建面板内容
this.panel.innerHTML = `
<div class="panel-header">
<span class="device-name">${deviceData.name}</span>
<span class="device-status status-${deviceData.status}">
${this.getStatusText(deviceData.status)}
</span>
</div>
<div class="panel-body">
<div class="metric">
<label>设备编号</label>
<span>${deviceData.id}</span>
</div>
<div class="metric">
<label>当前产量</label>
<span class="value-highlight">${deviceData.output} 件</span>
</div>
<div class="metric">
<label>OEE</label>
<span>${deviceData.oee}%</span>
</div>
<div class="metric">
<label>温度</label>
<span>${deviceData.temperature}°C</span>
</div>
</div>
<div class="panel-actions">
<button onclick="showHistory('${deviceData.id}')">历史趋势</button>
<button onclick="showDetail('${deviceData.id}')">详细信息</button>
</div>
`;
// 定位面板
this.panel.style.left = `${screenX + 15}px`;
this.panel.style.top = `${screenY + 15}px`;
this.panel.style.display = 'block';
// 边界检测:防止面板超出视口
this.adjustPosition();
}
hide() {
this.panel.style.display = 'none';
this.currentDevice = null;
}
adjustPosition() {
const rect = this.panel.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
if (rect.right > viewportWidth) {
this.panel.style.left = `${viewportWidth - rect.width - 10}px`;
}
if (rect.bottom > viewportHeight) {
this.panel.style.top = `${viewportHeight - rect.height - 10}px`;
}
}
getStatusText(status) {
return { running: '运行中', idle: '空闲', error: '故障', maintenance: '维护' }[status] || status;
}
}
四、相机漫游
工厂巡视场景需要相机自动漫游到指定设备位置。
平滑相机移动
class CameraController {
constructor(camera, controls) {
this.camera = camera;
this.controls = controls;
this.isAnimating = false;
this.animation = null;
}
flyTo(target, options = {}) {
const {
duration = 1500,
offset = new THREE.Vector3(5, 3, 5), // 相机相对偏移
lookAtOffset = new THREE.Vector3(0, 0, 0),
onComplete = null
} = options;
// 计算目标位置
const targetPosition = target.clone().add(offset);
const targetLookAt = target.clone().add(lookAtOffset);
// 起始状态
const startPos = this.camera.position.clone();
const startLookAt = this.controls.target.clone();
// 禁用控制器
this.controls.enabled = false;
this.isAnimating = true;
const startTime = performance.now();
const animate = () => {
const elapsed = performance.now() - startTime;
const t = Math.min(elapsed / duration, 1);
// 使用easeInOutCubic缓动函数
const eased = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
// 插值相机位置
this.camera.position.lerpVectors(startPos, targetPosition, eased);
this.controls.target.lerpVectors(startLookAt, targetLookAt, eased);
this.camera.lookAt(this.controls.target);
if (t < 1) {
this.animation = requestAnimationFrame(animate);
} else {
this.controls.enabled = true;
this.isAnimating = false;
if (onComplete) onComplete();
}
};
if (this.animation) cancelAnimationFrame(this.animation);
animate();
}
// 巡视路径漫游
flyAlongPath(waypoints, options = {}) {
const { durationPerPoint = 2000, onComplete } = options;
let index = 0;
const flyNext = () => {
if (index >= waypoints.length) {
if (onComplete) onComplete();
return;
}
this.flyTo(waypoints[index], {
duration: durationPerPoint,
onComplete: () => {
index++;
setTimeout(flyNext, 500); // 每个点停留500ms
}
});
};
flyNext();
}
}
五、剖切分析
查看设备内部结构或车间内部布局时,剖切功能很实用:
class ClippingPlane {
constructor(renderer) {
this.renderer = renderer;
this.renderer.localClippingEnabled = true;
// 创建剖切平面
this.plane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0);
// 剖切辅助面(可视化剖切位置)
this.helper = new THREE.PlaneHelper(this.plane, 20, 0xff0000);
this.helper.visible = false;
}
applyTo(material) {
material.clippingPlanes = [this.plane];
material.clipShadows = true;
material.needsUpdate = true;
}
setHeight(y) {
this.plane.constant = y;
}
setNormal(nx, ny, nz) {
this.plane.normal.set(nx, ny, nz).normalize();
}
toggleHelper(visible) {
this.helper.visible = visible;
}
}
六、性能优化总结
| 优化手段 | 效果 | 实现难度 |
|---------|------|---------|
| GPU 拾取替代 Raycaster | 拾取性能 O(1) | 中 |
| InstancedMesh | 重复对象渲染 draw call 降 90% | 低 |
LOD 分级 | 远距离渲染面数降 80% | 中 |
| 视锥裁剪 | 不可见对象不渲染 | 内置 |
| 交互防抖 | 减少 70% 的拾取计算 | 低 |
| Web Worker | 拾取计算不阻塞主线程 | 高 |
工业 3D 场景交互设计的核心原则:简单、稳定、可预期。 工厂管理人员不需要花哨的操作,他们要的是点一下设备就能看到数据、划一下就能看到趋势、切一下就能看到不同车间的状态。把这几个交互做好,比做十个炫酷但没人用的功能有价值得多。
数预智(广东)科技有限公司,专注工厂仿真/物流仿真/AGV仿真/数字孪生。www.forcastfuturetime.com