EMI数字孪生平台 — 面试问答
项目时间:2024年7月 - 2024年8月 | 工业设备3D模型展示与交互 技术栈:Vue3 + TypeScript + Three.js + Vite + Element Plus + TailwindCSS + v-scale-screen 线上地址:https://emi.goomaker.com/digital-twins
Q1: useThree 自定义 Hooks 架构设计
面试官可能问:"useThree 这个自定义 Hook 是怎么设计的?封装了哪些能力?"
回答:
项目拆成了两层:底层是 ThreeBase 静态类,上层是 useThree 组合式 Hook。
ThreeBase 静态类:把场景、相机、渲染器、控制器、TransformControls 的初始化逻辑抽成纯函数,每个都是 static 方法,输入 DOM 容器 / Camera,输出对应的 Three.js 对象。好处是跟 Vue 响应式完全解耦,任何地方都能复用,不依赖组件生命周期。
useThree Hook:用 Vue3 Composition API 的 shallowRef / shallowReactive 管理所有 Three.js 对象的引用——scene、camera、renderer、controls、model、composer 等 20+ 个对象都挂在 Hook 里。onMounted 时串行初始化:scene → camera → renderer → CSS2DRenderer → controls,然后按需调 initModelLoader、initGUI、initAxisHelper、initOuterBorderGlowLightEffect 等。
关键设计是用 requestAnimationFrame 驱动的渲染循环,在 render() 里统一处理自动旋转、controls 更新、composer 渲染、CSS2DRenderer 渲染,以及 Stats 性能监控的 begin/end。
追问:"为什么用 shallowRef 而不是 ref?"
Three.js 对象(Scene、Camera、Renderer 等)内部有大量嵌套属性,用 ref 会让 Vue 深度递归做响应式代理,性能开销很大而且完全没有必要——这些对象内部属性的变化不需要触发 Vue 的重新渲染。shallowRef 只追踪 .value 的替换,内部属性不改就不会触发响应式。
追问:"代码复用率 70% 是怎么算出来的?"
home 页(检测仪模型)、helmet 页(头盔模型)、factory 页(工厂模型)三个路由都复用同一套 useThree Hook。它们只在模型路径、纹理路径、相机参数上有差异,通过 props 传入即可。如果不抽 hooks,三个页面要各自写一遍完整的场景初始化代码(300+ 行 → 每个页面只需 20 行调 Hook),这就是 70% 复用率的来源。
Q2: 模型加载与 HDR 环境贴图
面试官可能问:"GLTF 模型和 HDR 环境贴图是怎么加载的?"
回答:
模型加载用 Three.js 的 GLTFLoader。封装了一个 Promise 化的 loadGLTF 函数,把回调风格的 loader 转成 async/await:
const loadGLTF = (url: string): Promise<GLTF> => {
const loader = new GLTFLoader();
return new Promise<GLTF>((resolve) => {
loader.load(url, (object: GLTF) => resolve(object));
});
};加载完成后把 gltf.scene 添加到场景,同时触发后续流程——添加文字标签(addLabelToModel)、绑定 WASD 移动控制、注册点击事件。
加载进度通过 THREE.DefaultLoadingManager 全局监听:onStart → onProgress(百分比更新)→ onLoad(隐藏 Loading 遮罩)→ onError(异常提示)。loadingMask 组件用 Element Plus 的 el-progress 展示进度条,加载完成后 1.5 秒淡出。
HDR 环境贴图用 RGBELoader 加载 .hdr 文件,设置 mapping = THREE.EquirectangularReflectionMapping 后同时赋给 scene.background 和 scene.environment——前者做背景天空盒,后者做 PBR 材质的环境反射。项目配了 4 套 HDR 贴图(studio、glass passage、metro、photo studio),不同场景切换不同氛围。
追问:"为什么用 GLTF 格式而不是 OBJ/FBX?"
GLTF 是 Khronos 标准的"3D 界的 JPEG",二进制格式加载快、文件小,原生支持 PBR 材质,Three.js 对它支持最好。OBJ 不带材质信息,FBX 是 Autodesk 私有格式、Three.js 加载器不完善。工业设备模型从 CAD 软件导出 GLTF 最方便。
追问:"多个模型并行加载怎么处理?"
loadModels 接收一个 Promise<any>[],内部直接 Promise.all(tasks),所有模型并行加载,都完成后统一渲染。不过当前项目一次只展示一个模型,所以实际是单模型加载流程。
Q3: 后处理特效与鼠标交互
面试官可能问:"悬浮轮廓光是怎么实现的?鼠标拾取延迟 <50ms 怎么做?"
回答:
用了 Three.js 的 Postprocessing 管线:EffectComposer + RenderPass + OutlinePass + OutputPass + FXAAShader。
轮廓光效果核心是 OutlinePass——它会给被选中的对象绘制边缘发光。参数全部通过 lil-gui 面板暴露可调:
edgeStrength:边缘强度(默认 3.0)edgeGlow:边缘光晕(默认 6.0)edgeThickness:边缘厚度(默认 3.0)pulsePeriod:脉冲周期(呼吸灯效果,默认 1.5)visibleEdgeColor/hiddenEdgeColor:可见/隐藏边缘颜色usePatternTexture:支持纹理图案填充边缘
鼠标拾取用的是 Raycaster,在 pointermove 事件里做 hit test:
- 获取鼠标在容器内的实际坐标。因为外层
v-scale-screen做了transform: scale()缩放,需要从getComputedStyle取出 scale 矩阵值,反向计算鼠标真实坐标 - 减去 100px 的 header 高度(header 也在缩放容器内,所以
headerHeight * scale) - 转为 NDC 坐标 →
raycaster.setFromCamera→intersectObject(scene, true) - 命中后用
CSS2DObject在鼠标位置显示浮层——模型名称 + UUID
追问:"为什么不用 requestAnimationFrame 里每帧做射线检测?"
没必要。pointermove 已经是事件驱动的,鼠标不动就不需要检测。在事件回调里做检测,响应延迟取决于射线计算本身(通常 <1ms)而非帧率(16ms),所以能做到 <50ms。
追问:"CSS2DRenderer 和 CSS2DObject 是什么关系?"
CSS2DRenderer 是一个独立的渲染器,在 WebGL Canvas 之上叠加一层 DOM,专门渲染 CSS2DObject——它本质上是一个带 position 的 DOM 元素,Three.js 根据 3D 坐标实时算出它在屏幕上的 2D 位置。这样标签文字随模型旋转/缩放而移动,但文字本身是 HTML/CSS 的,渲染清晰、样式灵活,不会被 WebGL 像素化。
Q4: lil-gui 可交互调试面板
面试官可能问:"GUI 调试面板做了什么?怎么设计的?"
回答:
用 lil-gui(Three.js 官方示例里的轻量 GUI 库)在场景右上角放了一个可折叠的调试面板,分了 6 个文件夹:
- 摄像机位置:X/Y/Z 滑块(步长 0.01),重置按钮恢复默认视角
- 轨道控制器:minDistance / maxDistance / dampingFactor,以及"调整中心点至模型"按钮
- 坐标轴辅助:显示/隐藏 toggle,X/Y/Z 三轴用 CanvasTexture 写的彩色文字 Sprite
- 模型文字标签:显示/隐藏 toggle
- 模型移动说明:WASDQE 键盘控制的说明文字,旋转轴选择(X/Y/Z),自动旋转开关+速度
- 轮廓光效果:7 个参数(edgeStrength/edgeGlow/edgeThickness/pulsePeriod/usePatternTexture/visibleEdgeColor/hiddenEdgeColor),以及重置模型位置按钮
还集成了 Stats 性能面板,左上角显示 FPS 和帧时间。
追问:"lil-gui 和 dat.gui 有什么区别?"
lil-gui 是 dat.gui 的现代替代品,API 几乎一模一样,但体积更小、支持 ES Module、对 Tree Shaking 友好。直接 import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js' 就行。
追问:"这些参数调的有什么用?"
主要是开发调试和客户演示。比如演示时客户想看模型的某个角度,直接拖摄像机位置滑块就能精确定位,不用手动旋转半天。轮廓光参数调整让非技术人员也能调出满意的视觉效果,不用改代码。
Q5: 屏幕适配与大屏展示
面试官可能问:"不同分辨率的屏幕怎么适配的?"
回答:
用 v-scale-screen 实现等比缩放。设计稿尺寸是 2560×1440,组件监听容器 resize 事件,算出实际宽高与设计尺寸的比例,用 CSS transform: scale() 等比缩放——宽度不够就按宽度比例缩小,高度不够就按高度比例缩小,取较小值确保不溢出。不管屏幕是 1920×1080 还是 4K,界面都完整展示。
背景用了网格线图案(linear-gradient 画的 30px 间距网格),配合深蓝色调,营造科技感大屏氛围。
追问:"v-scale-screen 和 CSS transform 缩放后,鼠标坐标怎么修正?"
这是项目里的一个坑。因为外层 scale 了,鼠标的 clientX/Y 和 Three.js 画布内的实际坐标不一致。我的做法是从 getComputedStyle 取出 transform 矩阵的第一个值(即 scaleX),然后用 (clientX - rect.left) / scale 反算真实坐标。
另外 header 高度 100px 也在缩放容器内,所以 Y 轴偏移要减去 headerHeight * scale 而非直接减 100。
追问:"全屏功能怎么实现的?"
封装了跨浏览器的 Fullscreen API 兼容层:requestFullScreen 兼容 webkitRequestFullScreen / mozRequestFullScreen / msRequestFullscreen,退出同理。按 F11 也会触发全屏。通过 fullscreenchange 事件监听全屏状态变化,图标按钮同步切换。
Q6: 内存管理与生命周期清理
面试官可能问:"Three.js 场景销毁时怎么清理内存?"
回答:
Three.js 不会自动回收 GPU 资源,必须手动 dispose。我在 clean() 函数里做了完整的清理链:
- 模型清理:
model.traverse遍历所有子节点,对 Mesh 和 Line 分别material.dispose()+geometry.dispose() - 停止渲染循环:
cancelAnimationFrame(RAFId) - 控制器销毁:
controls.dispose() - 渲染器销毁:
renderer.dispose()+renderer.forceContextLoss()→ 再用WEBGL_lose_context扩展强制释放 WebGL 上下文 - 场景清理:
scene.traverse再次遍历,确保所有 material 和 geometry 都 dispose - 引用置空:
camera = null,renderer.content = null,scene = null,帮助 GC 识别
所有事件监听(keydown/keyup/pointermove/resize/fullscreenchange)都在 onBeforeUnmount 里 remove。
追问:"为什么 forceContextLoss 之后还要 loseContext?"
forceContextLoss() 是 Three.js 的方法,会释放渲染器内部持有的 WebGL 资源。但它是异步的,loseContext() 是 WebGL 原生扩展,能强制同步释放 GPU 上下文。双重保险确保显存彻底回收,在模型切换频繁的场景下不会内存泄漏。
追问:"模型切换时怎么处理的?"
home 页的模型切换(回潮率 V1 → V2)是调用 modelLoaderRef.value.clean() → refreshElement = false → await nextTick() → refreshElement = true,通过 v-if 销毁重建整个 ModelLoader 组件,保证每次都是全新的 Three.js 场景。
Q7: 工厂模型的特殊处理
面试官可能问:"工厂模型跟普通模型处理有什么不同?"
回答:
工厂模型是一个大型化料工厂的完整 3D 场景——大货车、大货车1、大货车2、树、仓库、存储罐等多个独立模块组合在一起。跟单个检测仪模型的处理有几点不同:
1. 自定义光照:不用 HDR 环境贴图,改为手写 DirectionalLight(方向光,模拟日光)+ AmbientLight(环境光,补暗面),这样工厂场景更亮、更适合大屏展示。
2. 相机参数不同:透视角度从 45° 缩小到 30°(FOV),far 从 2000 调大到 3000。摄像机位置放到 (202, 123, 125) 做鸟瞰视角,maxDistance 从 100 扩大到 1000。
3. 模型分组(ancestors 模式):工厂模型的子模块是独立的逻辑单元(比如一辆大货车由多个 Mesh 组成),鼠标悬浮时需要高亮整个模块而非单个零件。所以我给每个子模块的 Mesh 加了一个 ancestors 引用指向父级 Group:
obj.traverse((child) => {
if (child.isMesh) {
child.ancestors = obj; // 指向父级 Group
}
});射线检测命中后,outlinePass.selectedObjects 设的是 ancestors 而非单个 Mesh,这样整辆货车一起发光。
4. 存储罐特殊处理:存储罐有两层嵌套,需要多循环一层找到内层 Group 再设置 ancestors。
追问:"为什么工厂模型单独写了一个 factory.vue 而不是复用 index.vue?"
因为差异太大了——光照、相机、模型预处理逻辑都不同。强行用一堆 if/else 和 props 控制会让组件变得臃肿难维护。"懒得改了直接复制一份"反而是务实的做法,两三百行的组件,清晰比复用更重要。
行为面试常见题快速答
"为什么做这个项目?"
公司做纺织行业的智能制造,需要把物理设备(回潮率检测仪、化料工厂)用 3D 方式展示给客户。数字孪生是大趋势——客户在网页上就能旋转、缩放查看设备结构,比翻产品手册直观得多。我从零搭建了这套 Three.js 方案。
"遇到的最大难点?"
v-scale-screen 的 transform 缩放和 Three.js 鼠标坐标系的适配。外层 scale 之后,clientX/Y 到 NDC 坐标的映射全乱了,特别是 Y 轴还要减去 header 高度(header 也在缩放容器里)。调试了很久才找到正确的公式:取 scale 矩阵 → 反算 → 减 headerHeight * scale。
还有就是 memory leak 问题。初期没做 dispose,切换模型时 WebGL 上下文没释放,切几次就卡死。后来系统性地加了完整的清理链,用 Chrome 的 about:gpu 和 Three.js 的 renderer.info 逐个排查残留资源。
"有什么遗憾?"
- 没有做模型动画——设备运行时状态的动态展示(比如旋转的风扇、流动的液体),现在都是静态模型。
- 没有接入实时数据——数字孪生的终极目的是把 IoT 数据映射到 3D 模型上(比如温度高的部件变红),目前还是纯展示。
factory.vue和index.vue有大量重复代码,没抽公共逻辑。虽然"懒得改"符合现实,但如果项目继续迭代,重构是必须的。
"跟其他项目(智慧SOP看板、回潮率检测仪)有什么关联?"
都是围绕纺织行业的智能制造——回潮率检测仪是硬件设备,SOP 看板是产线管理,数字孪生是设备 3D 展示。技术栈统一用 Vue3 + TypeScript + Vite,代码风格、工程化配置、部署方式都是一致的。比如 v-scale-screen 屏幕适配方案在 SOP 看板和数字孪生里都用到了。
上一篇:05-EMI智慧SOP看板(安灯系统) 下一篇:07-EMI协同管控平台