Skip to content

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 对象的引用——scenecamerarenderercontrolsmodelcomposer 等 20+ 个对象都挂在 Hook 里。onMounted 时串行初始化:scene → camera → renderer → CSS2DRenderer → controls,然后按需调 initModelLoaderinitGUIinitAxisHelperinitOuterBorderGlowLightEffect 等。

关键设计是用 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:

typescript
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 全局监听:onStartonProgress(百分比更新)→ onLoad(隐藏 Loading 遮罩)→ onError(异常提示)。loadingMask 组件用 Element Plus 的 el-progress 展示进度条,加载完成后 1.5 秒淡出。

HDR 环境贴图RGBELoader 加载 .hdr 文件,设置 mapping = THREE.EquirectangularReflectionMapping 后同时赋给 scene.backgroundscene.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:

  1. 获取鼠标在容器内的实际坐标。因为外层 v-scale-screen 做了 transform: scale() 缩放,需要从 getComputedStyle 取出 scale 矩阵值,反向计算鼠标真实坐标
  2. 减去 100px 的 header 高度(header 也在缩放容器内,所以 headerHeight * scale
  3. 转为 NDC 坐标 → raycaster.setFromCameraintersectObject(scene, true)
  4. 命中后用 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 个文件夹:

  1. 摄像机位置:X/Y/Z 滑块(步长 0.01),重置按钮恢复默认视角
  2. 轨道控制器:minDistance / maxDistance / dampingFactor,以及"调整中心点至模型"按钮
  3. 坐标轴辅助:显示/隐藏 toggle,X/Y/Z 三轴用 CanvasTexture 写的彩色文字 Sprite
  4. 模型文字标签:显示/隐藏 toggle
  5. 模型移动说明:WASDQE 键盘控制的说明文字,旋转轴选择(X/Y/Z),自动旋转开关+速度
  6. 轮廓光效果: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() 函数里做了完整的清理链:

  1. 模型清理model.traverse 遍历所有子节点,对 Mesh 和 Line 分别 material.dispose() + geometry.dispose()
  2. 停止渲染循环cancelAnimationFrame(RAFId)
  3. 控制器销毁controls.dispose()
  4. 渲染器销毁renderer.dispose() + renderer.forceContextLoss() → 再用 WEBGL_lose_context 扩展强制释放 WebGL 上下文
  5. 场景清理scene.traverse 再次遍历,确保所有 material 和 geometry 都 dispose
  6. 引用置空camera = nullrenderer.content = nullscene = 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 = falseawait 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:

typescript
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 逐个排查残留资源。

"有什么遗憾?"

  1. 没有做模型动画——设备运行时状态的动态展示(比如旋转的风扇、流动的液体),现在都是静态模型。
  2. 没有接入实时数据——数字孪生的终极目的是把 IoT 数据映射到 3D 模型上(比如温度高的部件变红),目前还是纯展示。
  3. factory.vueindex.vue 有大量重复代码,没抽公共逻辑。虽然"懒得改"符合现实,但如果项目继续迭代,重构是必须的。

"跟其他项目(智慧SOP看板、回潮率检测仪)有什么关联?"

都是围绕纺织行业的智能制造——回潮率检测仪是硬件设备,SOP 看板是产线管理,数字孪生是设备 3D 展示。技术栈统一用 Vue3 + TypeScript + Vite,代码风格、工程化配置、部署方式都是一致的。比如 v-scale-screen 屏幕适配方案在 SOP 看板和数字孪生里都用到了。

上一篇:05-EMI智慧SOP看板(安灯系统) 下一篇:07-EMI协同管控平台

前端开发工程师 · 面试准备