工业3D场景交互设计:从拾取、高亮到漫游的完整实现方案

0 阅读5分钟

工业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