Three.js 3D展厅开发实战:场景搭建、模型加载与交互全攻略 1. 用Three.js做3D展厅为什么这个选型值得赌一把先说说我为什么会走这条路。手头有个工业设备的项目客户要求“能在网页上看模型而且要能转、能缩放最好有展厅的感觉”。传统做法是录一段旋转展示视频或者做一组全景图但视频和全景图都是“被动观看”客户想看背面还得等视频转过去体验很割裂。也想过用Unity打包WebGL但Unity的WebGL产物动辄几十MB加载慢而且为了一个展示功能引一整套游戏引擎属实有点重。后来我定了WebGLThree.js的方案。简单解释一下这两者的关系WebGL是浏览器底层的图形API它允许JavaScript直接操作GPU绘制图形但直接用WebGL写代码非常痛苦——画一个旋转的三角形就得几百行。Three.js是构建在WebGL之上的3D库把场景、相机、灯光、材质、加载器这些都封装成了可用的对象你只需要关心“场景里放什么”不需要关心“GPU怎么渲染”。说得直白一点WebGL是发动机Three.js是方向盘你开的是车不用真的去拧螺丝。这个选型到底值不值得赌我从三个维度做了评估维度Three.js方案录视频/全景图Unity WebGL交互性自由旋转/缩放/切换无或极弱强但杀鸡用牛刀加载体积模型压缩后通常几百KB~几MB视频体积大动辄20MB以上开发成本低生态成熟低但效果受限高需学习Unity部署难度纯静态资源任意Web服务器简单需要配置较多如果你只是给客户看一眼最终效果录个视频确实够但如果客户会在不同设备上反复查看甚至需要嵌入到官网、公众号、小程序WebView里Three.js这条路几乎是唯一兼顾“体验”和“开发成本”的选择。还有一点很多人忽略Three.js的渲染效果在加载合适的PBR材质和HDR环境贴图后已经能达到非常接近离线渲染的质感。也就是说只要美术资源过关网页展示效果并不比渲染器出图差多少而且它还能交互。这是视频和全景图完全没法比的。2. 先把场景跑起来渲染器、相机、灯光的一套合理配置展厅的“壳”长什么样决定了模型展示的上限。我在搭建场景时核心是渲染器、相机、灯光这三件事每一件的参数都不是随便设的下面逐个说。2.1 渲染器配置antialias和pixelRatio是关键创建渲染器的代码很简单但这里有两个坑必须一开始就处理好const renderer new THREE.WebGLRenderer({ antialias: true, // 抗锯齿 alpha: true, // 透明背景方便叠加页面样式 preserveDrawingBuffer: true // 截图功能需要视情况开 }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.shadowMap.enabled true; renderer.shadowMap.type THREE.PCFSoftShadowMap; renderer.toneMapping THREE.ACESFilmicToneMapping; renderer.toneMappingExposure 1.2;antialias必须在创建渲染器时设置之后改不了。很多初学者忘了开模型边缘全是锯齿还以为是模型的锅。setPixelRatio我这里取了设备像素比和2的较小值——在高分屏如Retina上如果不用像素比画面会糊但如果设备像素比是3你直接乘3GPU的负担会变成9倍滑动都卡。取2是一个画质和性能的平衡点实测在4K屏上也够锐利。toneMapping这里用了ACESFilmicToneMapping这个是电影工业的色调映射曲线能让高光更柔和、暗部细节更丰富简单说就是“看起来更高级”。配合toneMappingExposure调整曝光模型展示的明暗过渡会自然很多。2.2 相机参数为什么fov用45而不是75const camera new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(5, 4, 8); camera.lookAt(0, 1, 0);透视相机的fov视场角决定了视野宽度。很多Three.js教程会用60或75但展厅场景建议用45原因有两个第一fov越大近处物体的透视畸变越严重模型靠近屏幕时边缘会明显拉变形这对展示类应用来说是致命的第二45度更接近人眼的感知习惯模型看起来更“真实”。反过来如果你做的是游戏或者沉浸式场景fov大一点反而有代入感但展厅的核心是把模型展示清楚不是让观众晕。near和far是近远裁剪面0.1到1000这个范围对绝大多数展厅场景够用了。需要注意一点near设太小会导致深度冲突z-fighting太大又可能在物体靠近相机时被裁掉我一般取0.1起步如果模型需要贴得很近观看再适当调整。2.3 灯光组合环境光主光氛围光灯光是展厅的灵魂。我见过太多人只加一个AmbientLight结果模型像白纸一样3D感全无。展厅场景我推荐三层光// 环境光保证暗部不死黑 const ambient new THREE.HemisphereLight(0xffffff, 0x444444, 0.4); scene.add(ambient); // 主光模拟主要照明方向 const directional new THREE.DirectionalLight(0xffffff, 2.5); directional.position.set(5, 10, 7); directional.castShadow true; scene.add(directional); // 展台重点光让模型更突出 const spotLight new THREE.SpotLight(0xffffff, 30); spotLight.position.set(0, 12, 0); spotLight.angle 0.3; spotLight.penumbra 0.5; spotLight.decay 1; scene.add(spotLight);HemisphereLight半球光和AmbientLight的区别在于半球光从天蓝过渡到地暗能模拟自然光在物体顶部和底部的差异比纯环境光立体得多。DirectionalLight平行光模拟太阳方向性明确能让模型有明显的受光面和背光面是立体感的主要来源。SpotLight聚光灯在这里是点睛之笔它会在地面和模型上投下一个汇聚的光斑观众一眼就能看出“这个展台是精心布置过的”。关于SpotLight的强度如果你用的Three.js版本较新r155物理单位的光强度需要试验调整不要照抄老教程的数字。我这里的30是配合decay: 1和toneMapping的效果实际项目中建议先跑起来再调以画面不过曝为准。2.4 地面与展台一个干净的立方体也有讲究展厅的“地面”不能是一块纯色平面否则投影看不出来。我通常会用一个大平面加上一个展台底座const plane new THREE.Mesh( new THREE.CircleGeometry(8, 64), new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.8, metalness: 0.2 }) ); plane.rotation.x -Math.PI / 2; plane.receiveShadow true; scene.add(plane); // 底座 const pedestal new THREE.Mesh( new THREE.CylinderGeometry(1.2, 1.4, 0.2, 64), new THREE.MeshStandardMaterial({ color: 0x222222, roughness: 0.5, metalness: 0.6 }) ); pedestal.position.y 0.1; pedestal.receiveShadow true; scene.add(pedestal);注意圆形地面而不是正方形因为圆形在任意旋转角度下都不会穿帮。展台底座的圆柱体形态也能让视觉焦点聚拢在模型上。阴影的receiveShadow别忘了开这会让模型与地面之间产生真实的接地感没有影子的话模型就像悬浮在场景里非常假。3. 模型进入展厅的正确姿势格式选择与加载细节场景搭好了接下来是把模型放进展厅。这一步踩坑概率最高尤其是格式选错后面全白干。3.1 格式选型无脑选GLB没错Three.js支持几十种模型格式加载不同格式需要不同的Loader但我的建议是业务场景一律用GLB。格式是否内嵌贴图PBR材质压缩支持适用场景GLB是支持Draco通用展示首选glTF否外部引用贴图支持Draco需要编辑资源的场景OBJ否不支持无兼容老流程FBX是部分无动画制作流程STL否不支持无3D打印GLB和glTF本质是同一种格式GLB是glTF的二进制单文件版本模型、贴图、材质数据全打在一个文件里部署时不用考虑路径问题特别适合网页加载。OBJ是历史遗留格式不支持PBR材质贴图要靠外部文件加载后材质质感会很差。FBX适合带骨骼动画的角色模型但文件往往很大浏览器端解析也慢。建议在3D建模软件Blender、3ds Max、C4D等里把材质调好然后导出为GLB。在Blender里要注意材质用Principled BSDF原理化BSDF导出时勾选“Apply Modifiers”和“Export Tangents”这样Three.js加载后渲染效果最接近建模软件里的效果。3.2 加载流程从进度到落地的完整代码import { GLTFLoader } from three/examples/jsm/loaders/GLTFLoader.js; import { DRACOLoader } from three/examples/jsm/loaders/DRACOLoader.js; // Draco压缩支持 const dracoLoader new DRACOLoader(); dracoLoader.setDecoderPath(https://www.gstatic.com/draco/versioned/decoders/1.5.6/); const loader new GLTFLoader(); loader.setDRACOLoader(dracoLoader); loader.load( models/device.glb, (gltf) { const model gltf.scene; // 模型居中、缩放的核心逻辑 const box new THREE.Box3().setFromObject(model); const size box.getSize(new THREE.Vector3()); const center box.getCenter(new THREE.Vector3()); // 把模型中心移到原点 model.position.x - center.x; model.position.z - center.z; // 让模型底部贴到展台表面 model.position.y - box.min.y; // 将模型适配到目标尺寸 const targetSize 1.5; const maxDim Math.max(size.x, size.y, size.z); const scale targetSize / maxDim; model.scale.setScalar(scale); model.traverse((child) { if (child.isMesh) { child.castShadow true; child.receiveShadow true; } }); pedestal.add(model); }, (xhr) { const percent Math.floor((xhr.loaded / xhr.total) * 100); console.log(加载进度${percent}%); }, (error) { console.error(模型加载失败, error); } );这中间最关键的是“居中”和“适配尺寸”这两步。很多模型在建模软件里没对齐原点或者建模单位不统一有人用毫米有人用米直接加载进场景要么跑到展厅外面要么小到看不见。上面的代码统一做了三件事水平居中、底部对齐展台表面、把最长边缩放到1.5个单位。这样无论美术给什么模型落地后都在展台正中央视觉大小一致观感非常稳定。3.3 关于加载跨域的问题为什么本地双击打不开如果你直接在浏览器里双击本地HTML文件然后加载本地GLB模型十有八九控制台会报Access to XMLHttpRequest at file:///... from origin null这样的错。这不是Three.js的问题而是浏览器的安全策略默认禁止“file://”协议下的页面发请求读取本地文件这叫跨域限制。解决办法有几种。最推荐的是本地起一个HTTP服务器# 任意目录执行 npx serve . # 或者Python python -m http.server 8080然后浏览器访问http://localhost:8080模型就能正常加载了。如果模型放在CDN或对象存储上需要在服务端配置CORS头允许跨域读取。早期我在内网环境踩过这个坑好几天都没搞明白后来发现就是少了跨域头白白浪费了时间。3.4 加载管理器和全局加载提示展厅如果不止一个模型建议用LoadingManager统一管理const manager new THREE.LoadingManager(); manager.onStart (url, loaded, total) { showLoadingOverlay(); }; manager.onProgress (url, loaded, total) { updateProgress(loaded / total); }; manager.onLoad () { hideLoadingOverlay(); };界面体验上不要让用户在空白页面等太久。至少加一个进度条或者“正在载入模型”的提示不然用户一看到白屏大概率直接关页面。进度信息用loaded/total计算展示为百分比直观且有效。4. 让观众动起来展厅交互与多模型切换的实现路径一个静态的3D模型再怎么好看如果不让用户操作和图片没区别。展厅的交互核心是“旋转”“缩放”“切换”三件事。4.1 OrbitControls开箱即用的轨道控制import { OrbitControls } from three/examples/jsm/controls/OrbitControls.js; const controls new OrbitControls(camera, renderer.domElement); controls.target.set(0, 1.5, 0); controls.enableDamping true; // 阻尼效果手感更顺滑 controls.dampingFactor 0.08; controls.minDistance 2; controls.maxDistance 12; controls.maxPolarAngle Math.PI / 2.2; // 防止看到展台底下 controls.autoRotate true; controls.autoRotateSpeed 1.5;enableDamping必须设为true并在动画循环里调用controls.update()否则旋转会有一段“猛停”的突兀感。minDistance和maxDistance限制了缩放范围可以防止用户把相机怼进模型内部或者拉得太远导致找不到目标。maxPolarAngle限制俯仰角避免用户把视角转到地面以下看到场景外的“虚空”瞬间出戏。autoRotate是我强烈建议开启的。客户打开页面后模型会自动缓慢旋转30秒内就能看清整个外观不需要任何操作。这是“展厅”和“模型查看器”的最大区别——展厅有导览动线观众不需要主动思考也知道看什么。这个细节在实际交付中很容易打动非技术背景的客户。4.2 多模型切换列表、相机位置与加载策略展厅通常不止一件展品。我这里的做法是维护一个模型配置数组每个条目包含模型路径、名称、展台描述再渲染一组缩略图按钮点击后切换模型const models [ { name: 设备A, path: models/device-a.glb, cameraPos: [4, 3, 6] }, { name: 设备B, path: models/device-b.glb, cameraPos: [3, 2.5, 5] }, { name: 设备C, path: models/device-c.glb, cameraPos: [5, 4, 7] }, ]; // 切换函数 async function switchModel(index) { // 清理当前模型 if (currentModel) { pedestal.remove(currentModel); disposeModel(currentModel); // 递归释放几何体、材质、纹理 } // 加载新模型 currentModel await loadModel(models[index].path); pedestal.add(currentModel); // 平滑复位相机 const pos models[index].cameraPos; gsap.to(camera.position, { x: pos[0], y: pos[1], z: pos[2], duration: 0.8 }); }这里的几个细节值得展开。模型清理是很容易忽略但非常重要的一步。如果切换模型时只是scene.remove(oldModel)而没释放掉旧的几何体、材质和纹理浏览器的GPU内存会越积越多几轮切换后页面就会卡死。释放方法是对每个mesh遍历调用geometry.dispose()、material.dispose()如果是纹理材质还要texture.dispose()。这块代码比较琐碎我习惯封装成一个递归函数。切换动画我用了GSAPGreenSock它可以把相机位置变化做成平滑缓动比直接camera.position.set()高级很多。没有GSAP的话用Three.js的TWEEN库也能实现但GSAP功能和性能都更好全项目也就几KB值得引入。每个模型独立相机位是我最后总结出来的经验不同尺寸的模型适合观察的距离不一样。一个大柜子和一个小铰链如果共用一套观察距离体验会非常割裂。所以在配置数组里给每个模型单独设一个初始相机位置切换时同时移动相机观众会感觉“这个展馆是为每件展品量身定制的”。4.3 缩略图UI与页面集成展厅不能只靠3D场景本身页面HTML需要提供切换入口和信息区。我这里做了一个很轻的结构div idmodel-list button classthumb>let renderer; try { renderer new THREE.WebGLRenderer({ antialias: true }); } catch (e) { document.getElementById(webgl-error).style.display block; return; }WebGLRenderer创建失败时会抛出异常捕获后给用户展示一个友好的提示页面告诉他们“当前浏览器不支持WebGL请开启硬件加速或更换浏览器”而不是让用户面对一个白屏。降低WebGL2依赖从Three.js r163开始新版本默认使用WebGL2但如果WebGL2不可用Three.js也会尝试回退到WebGL1。如果你的项目必须兼容老设备建议锁定一个相对保守的Three.js版本并且在初始化时显式检查const canvas document.createElement(canvas); const gl canvas.getContext(webgl2) || canvas.getContext(webgl); if (!gl) { // 彻底不支持WebGL return; }5.2 坑二模型加载报跨域错误现象模型放在内网服务器上页面部署在另一个域名加载GLB时报错Access to XMLHttpRequest at http://some-server/models/a.glb from origin http://my-page.com has been blocked by CORS policy: No Access-Control-Allow-Origin header is present on the requested resource.根因浏览器为了安全默认禁止一个源页面所在的域名向另一个源模型文件所在的域名发跨域请求。如果模型服务器的响应头里没有Access-Control-Allow-Origin浏览器就会拦截。完整排查链路先区分是开发环境还是生产环境。开发环境本地localhost的跨域问题通常用HTTP服务器就能解决如前面说的python -m http.server。生产环境模型文件如果和页面在同一域名下不会有这个问题如果模型放在OSS/COS/CDN/对象存储上需要在存储服务的控制台里配置CORS规则。比如阿里云OSS的“跨域设置”里添加一条规则允许来源设为*或你的页面域名允许方法选择GET暴露请求头可不填。如果服务器是Nginx需要在location块里加头location /models/ { add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods GET, OPTIONS; }还有一个偏门但有效的方案用fetch把模型文件先拉成Blob再用URL.createObjectURL生成一个同源的临时URL把它传给GLTFLoader。这种方式能绕开模型URL的跨域限制但前提是获取Blob的fetch请求本身不跨域。如果服务器不支持CORS这个方案也无效const response await fetch(http://some-server/models/a.glb, { mode: cors }); const blob await response.blob(); const blobUrl URL.createObjectURL(blob); // 用blobUrl加载模型经验总结跨域问题最省事的方案是——让模型和页面同源。把模型文件放到网站的静态资源目录下和页面共用同一个域名永远不会有跨域问题。只有模型量大要上CDN时才需要考虑CORS配置这时候一定要提前和服务端/运维沟通好而不是等到测试时才发现。5.3 坑三模型尺寸异常和中心点偏移现象加载一个从ADAltium Designer导出的3D模型模型在展厅里变成了一个“巨物”相机拉远到极限都看不到全貌或者模型歪歪斜斜不在展台中央像被遗弃的箱子。根因这里面通常叠加了两个问题。第一单位不一致。AD、SolidWorks、立创EDA这些工具导出的STL/STEP/OBJ模型单位可能是毫米、厘米或者英寸但Three.js里默认1个单位1米。如果模型单位是毫米导入后尺寸就是实际尺寸的1000倍自然变成巨物。第二建模时对象没对齐原点。很多EDA工具导出时把模型原点放在世界坐标原点但模型的几何中心并不在原点导致加载后模型整体偏移。完整排查链路拿到模型后先用Three.js的Box3打印一下包围盒信息看尺寸和中心const box new THREE.Box3().setFromObject(model); console.log(尺寸, box.getSize(new THREE.Vector3())); console.log(中心, box.getCenter(new THREE.Vector3())); console.log(最小点, box.min);如果尺寸是[800, 600, 30]这样的数量级几乎可以断定原始单位是毫米。如果是[2.5, 3, 4]那单位应该是米。单位换算最简单的方法是缩放模型节点。对应前面代码里的model.scale.setScalar(0.001)把毫米转成米。但这里有个坑如果模型里嵌入了动画、骨骼、物理碰撞体直接缩放scene可能导致这些绑定错乱。稳妥做法是在导出前就让美术/设计在建模软件里调整好单位或者只加载gltf.scene并递归缩放所有mesh节点。处理几何中心偏移用前面提到的居中逻辑计算包围盒中心让模型位置减去中心坐标。但注意如果模型有子层级直接改model.position可能不够因为子节点可能还有局部偏移。我处理这种问题时优先改model.position如果模型明显偏了再递归遍历每个子mesh统一调整。针对EDA工具链的补充立创EDA、AD导出3D模型时建议在导出设置里确认导出单位。AD导出STEP时默认单位通常是毫米立创EDA的3D模型导出选项里有毫米和英寸可选务必和机械设计师确认清楚再导出。否则页面上的比例尺会完全失控。5.4 坑四补充Three.js结合geojson做3D地图时标注文字总是偏移这个虽然不是展厅的主线但我看到最近不少人在问“three.js结合geojson实现跑平面3d地图但是地图上的行政区名称总是偏移位置”。这本质上是坐标系对齐的问题和模型偏移是同一个根因。geojson里的坐标是经纬度你需要把它转换成Three.js世界坐标。比较常见的做法是用d3-geo的geoMercator()或geoEquirectangular()投影把经纬度转成平面坐标。但转换时要注意两点投影函数的scale和translate必须和地图底板的尺寸对应否则标注文字会整体偏到地图外面。文字用CSS2DRenderer或Sprite渲染时position要设在“转换后的坐标 一个偏移量”上而不是直接把投影坐标塞进去。如果文字偏移量是固定的很可能是投影的scale/translate没配对如果偏移量随地图中心变化检查是不是用错了投影中心或基准面。这块的排查思路是先输出一个已知地点的投影坐标和底图上对应位置的像素关系对比就知道偏差来自哪里了。6. 最后再说两句实话关于Three.js做3D展厅这件事我最后分享几条切身体会。第一版本锁定很重要。Three.js迭代非常快r150到r160之间API就有不少变化比如renderer.outputEncoding改成renderer.outputColorSpaceuseLegacyLights默认值也变了。如果你参考了网上的老教程请先确认你用的版本和教程一致或者直接看当前版本的文档。我建议把three的版本号固定到某个具体版本不要用^来装最新版不然某天依赖更新了项目突然不渲染了排查起来非常痛苦。第二模型资源本身比代码更影响效果。代码做得再花哨模型的材质、UV、拓扑如果不行出来的画面一样拉胯。如果你不是3D美术出身尽早和负责模型的同事确认三件事材质是否PBR、导出是否为GLB、单位是否统一。这三件事在模型源头解决比在代码里打补丁强一百倍。第三性能优化永远是后置的。不要一上来就想着用各种LOD、Draco压缩、纹理压缩先把功能跑通然后打开性能面板Chrome DevTools的Performance看实际瓶颈。一般情况下一个展厅里放3到5个模型、每个模型几MB资源量现代设备完全跑得动。真正卡的时候往往是某一个模型面数上千万级或者纹理是几张大图那时候才需要针对性地做减面、压缩和Draco编码。第四桌面端和移动端的差异。移动端的GPU性能普遍弱于桌面尤其是在低端安卓机上pixelRatio我建议在移动端就设成Math.min(devicePixelRatio, 1.5)牺牲一点清晰度换取流畅度。相机最大移动距离也要更大一些因为手机屏幕小用户需要拉远一点才能看全模型。最后给大家一个很实用的小建议项目交付后记得保留一个专门用于“环境检测”的诊断页面放置WebGL支持测试、浏览器版本、GPU信息、模型加载耗时等关键指标。售后阶段用户反馈白屏时让对方打开这个诊断页你就能直接定位是浏览器环境问题还是代码问题而不是隔着屏幕瞎猜。这招帮我节省了大量远程排查时间强烈推荐。