☰
网页3D模型展示全攻略:Three.js、glTF/GLB与交互分享
2026/9/29 1:23:47 网站建设 项目流程

你有没有遇到过这种情况:辛辛苦苦用 Blender、SolidWorks 或者 C4D 建好了一个 3D 模型,发给客户看,对方要么得装软件,要么得下载好几个 G 的工程文件,最后只能截几张图凑合看。尤其是做工业设备、文物数字化、建筑方案汇报的时候,静态图片根本讲不清楚结构关系,客户反复问“这个角度能看到背面吗”“内部结构能不能拆开看”,你只能干瞪眼。

把 3D 模型直接放到网页里,发一个链接就能让对方打开浏览器随便转、随便看、随便点,甚至还能自己拖拽、旋转、缩放,看完还能一键分享给同事——这套流程其实没那么玄乎,而且现在的技术成熟度已经很高了。这篇文章我就把从零开始做网页 3D 模型展示的完整路径拆开讲清楚,包括技术选型的思路、模型格式怎么处理、展示浏览互动分享每一步怎么落地,以及我在实际项目里踩过的一堆坑。适合想做产品展示页的开发者、需要交付可视化方案的工程师,还有单纯想把自己做的模型放上网的设计师。

1. 内容整体设计与思路拆解

1.1 先搞清楚“网页 3D 展示”到底要解决什么问题

很多人的第一反应是“用 WebGL 渲染一个模型进去”,但这只是最表层的动作。我做了几个项目之后再回头看,发现网页 3D 展示本质上是三件事的打包:资产轻量化、渲染实时化、交互人性化。

资产轻量化说的是模型文件不能在网页里加载一个几百 MB 的原始工程文件,必须转成浏览器能高效解析的格式,比如 glTF/GLB。渲染实时化意味着所有画面都是 GPU 实时算出来的,所以视角可以任意切换,不像预渲染视频那样只能沿着固定路径走。交互人性化则是说浏览者能通过鼠标拖拽、滚轮缩放、点击热区这些最自然的方式去理解模型。

这三个需求决定了整个技术路线的走向。如果你只是想把一个模型放到网页上“能看就行”,那可以用一些傻瓜工具一键生成;但如果要做到流畅浏览和丰富互动,就必须自己掌握核心渲染引擎的控制权。我的建议是——除非你的模型非常简单、交互需求几乎为零,否则尽量选择自己掌控渲染方案,因为一旦后续要加功能,被封装好的工具会极度束缚手脚。

1.2 技术选型:Three.js 是当下性价比最高的答案

现在主流的 WebGL 渲染方案无非就几个:原生 WebGL、Three.js、Babylon.js,以及近年比较火的 WebGPU 方向。我直接说结论:没有特殊原因,就用 Three.js。

原生 WebGL 的问题是开发效率太低——你要自己管理缓冲区、着色器、矩阵变换,写一个能转视角的场景就得几百行代码,而且还要处理各种兼容性问题。WebGL 本质上是一个光栅化接口,不是给你做场景管理的,直接用它建一个“可浏览的 3D 展厅”就像用汇编语言写操作系统,能写,但没必要。

Babylon.js 也很强大,而且它的内置 UI、场景编辑器都做得不错,但生态和资料比 Three.js 少一截。真实感受是,遇到问题时 Three.js 的解决方案一搜一大把,而 Babylon.js 经常要自己啃源码。对于大多数项目来说,这个差距很致命。

Three.js 的优势在于:API 设计直观、官方文档和示例丰富、社区庞大、扩展库齐全(后面要讲的轨道控制器、射线检测、模型加载器都是它的生态)。而且它底层的 WebGL 渲染器经过多年优化,性能和兼容性都很成熟。如果你未来想升级到 WebGPU,Three.js 也提供了对应的渲染器支持,算是给自己留了一条升级路径。

1.3 方案选型背后的核心考量:性能、开发效率与场景适配

选技术路线时,我一般看三个维度:模型复杂度、交互深度、目标设备。

模型复杂度决定了你要不要在压缩和 LOD(多层次细节)上花功夫。交互深度决定了你是只需要轨道控制还是要做点击拾取、动画播放、模型拆解。目标设备则直接决定渲染精度——如果客户主要用手机看,你按 PC 端标准做高精材质,最终结果就是卡成幻灯片。

另外一个常被忽略的点是工程化能力。Three.js 可以很好地融入 Vite、Webpack 这些现代前端工程体系,组件化之后复用性很强。我以前接过一个数字化展厅项目,里面要放二十多个可交互设备模型,如果每个页面都从零搭场景,维护成本简直不敢想。后来抽了一个公共组件,传入模型路径和配置项就能生成可交互的展示模块,后面接新展品只需要准备模型和写几行配置,效率直接倍增。

2. 环境准备与模型资产处理

2.1 搭建一个现代化的 Three.js 项目

我推荐直接用 Vite 搭项目,比传统的 script 标签引入方式舒服太多。 Vite 启动快、热更新及时,而且内置了对 glTF/GLB 等静态资源的处理能力。具体操作如下:

npm create vite@latest web3d-demo -- --template vanilla cd web3d-demo npm install three npm install --save-dev vite-plugin-static-copy

然后在main.js里引入 Three.js 核心库:

import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

注意,Three.js 从 r150 左右开始,示例模块的导入路径统一走three/addons/,直接在 node_modules 里找路径容易迷路,最好用官方推荐的导入方式。

2.2 模型格式选择:glTF/GLB 是王道,其他格式看情况

做网页 3D 展示,模型格式的选择直接决定后续流程顺不顺。我踩过的坑排序大概是:OBJ 没有材质层级、FBX 依赖 Maya/3ds Max 的坐标系和动画配置、STL 只有三角面没有任何材质信息、3DS 更是远古产物。

所以答案是:尽量用 glTF/GLB。glTF 被称为“3D 界的 JPEG”,设计目标就是为 GPU 实时渲染而生。它把网格、材质、骨骼动画、摄像机、灯光等信息打包成结构化数据,而且支持二进制容器格式 GLB——一个文件装下所有资源,前端加载特别方便。

如果你的原始模型是 SolidWorks 导出的 STL,或者 CAD 转了 OBJ,我建议先用 Blender 做一次中转,把模型导入后重新导出为 GLB。这个过程顺便可以做减面、展 UV、烘焙贴图等优化。注意:从 Blender 导出时,默认坐标轴是 Z 轴向上,Three.js 的场景是 Y 轴向上,所以导出时需要正确设置变换,不然模型会“躺”在场景里。

2.3 模型优化:Draco 压缩与纹理处理是流畅的关键

一个高精模型动辄几十上百 MB,直接放到网页里加载,用户等 10 秒还没看到东西早就关页面了。这里必须做减法和压缩。

几何压缩我用的是 Google 的 Draco。它是专门为 3D 网格设计的压缩算法,压缩率能达到 80%~90%。Three.js 官方提供了 DracoLoader,使用方式如下:

import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'; const dracoLoader = new DRACOLoader(); dracoLoader.setDecoderPath('https://www.gstatic.com/draco/v1/decoders/'); loader.setDRACOLoader(dracoLoader);

注意 Draco 的 decoder 文件要放到可访问的静态路径下,本地开发时可以直接引用 gstatic 的 CDN,生产环境建议把 decoder 文件下载到自己的服务器,避免外部依赖。

纹理处理上,我一般的做法是:漫反射贴图、法线贴图、粗糙度贴图都压缩到 2048 或 1024 分辨率,格式转成 WebP 或 JPEG(根据是否有透明通道选择)。Three.js 的texture.colorSpace记得设为THREE.SRGBColorSpace,否则颜色会偏淡发灰。这个细节很多人忽略,出来的效果就是颜色不饱和,客户一看就说“质感不对”。

3. 核心功能实现:展示、浏览、互动到分享

3.1 展示:写一个能加载 GLB 模型的最小渲染场景

先把最核心的渲染流程跑通。一个 Three.js 场景最少要有三样东西:场景容器、相机、渲染器。下面这段代码是基础骨架:

import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; const scene = new THREE.Scene(); scene.background = new THREE.Color(0xf5f5f5); const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(5, 4, 8); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.shadowMap.enabled = true; document.body.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; // 灯光:环境光保证整体可见,平行光产生阴影,半球光补光 const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); const dirLight = new THREE.DirectionalLight(0xffffff, 1.2); dirLight.position.set(10, 20, 10); dirLight.castShadow = true; scene.add(dirLight); const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.4); scene.add(hemiLight); // 加载模型 const loader = new GLTFLoader(); loader.load('/models/product.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.sub(center); const maxDim = Math.max(size.x, size.y, size.z); const scale = 4 / maxDim; model.scale.setScalar(scale); scene.add(model); }); function animate() { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); } animate(); // 窗口尺寸变化时自适应 window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); });

这段代码跑起来,你就能在浏览器里看到一个可以旋转、缩放、平移的 3D 模型了。Box3归一化这段逻辑非常重要——不同建模软件导出的模型尺寸千差万别,有的单位是米有的是厘米,有的模型在世界坐标系里离原点十万八千里。如果不做归一化,经常出现模型一个面怼到相机脸上,或者小成一个点的情况。

3.2 浏览:轨道控制器的参数调优与多视角体验

OrbitControls 默认就能用,但想让浏览体验更“专业”,有几个参数值得花心思调。

第一个是minDistance和maxDistance。这两个值限制相机缩放范围,避免用户把视角拉到模型里面看到穿模的内表面,或者拉太远模型变成一个小点。比如一个 4 个单位大小的模型,我一般把 maxDistance 设为 20,minDistance 设为 1.5。

第二个是maxPolarAngle。这个限制垂直旋转角度,防止用户转到模型正下方仰视,把底部的三角面暴露出来。比如产品展示一般限制在 0 到 Math.PI 之间,也就是不转到正下方。

第三个是阻尼(enableDamping)。我之前测试发现,开了阻尼之后旋转手感明显更平滑,有种“重量感”,不会一松鼠标就“啪”地停住。这个参数的调优直接决定用户对展示页面的第一印象——很多人说“你的页面看起来很高级”,其实就是阻尼做得好。

更进阶一点的多视角体验,可以预设几个视角按钮,比如“正面”“侧面”“俯视”“45° 黄金视角”。实现方式其实很简单,就是用gsap或自写的补间动画把相机位置平滑过渡到预设坐标:

function flyTo(position, target) { // 用一个简单的时间插值实现平滑过渡,也可以用 GSAP const startPos = camera.position.clone(); const startTarget = controls.target.clone(); const duration = 800; const startTime = performance.now(); function update() { const elapsed = performance.now() - startTime; const progress = Math.min(elapsed / duration, 1); const ease = 1 - Math.pow(1 - progress, 3); // easeOutCubic camera.position.lerpVectors(startPos, position, ease); controls.target.lerpVectors(startTarget, target, ease); controls.update(); if (progress < 1) requestAnimationFrame(update); } update(); }

3.3 互动:射线检测、模型拆解与热点标注

浏览只是基本功,真正的亮点在于互动。最常见的三个互动场景:点击模型部件高亮、模型拆解/爆炸视图、热点标注弹窗。

点击高亮的核心是射线检测。原理也简单:当用户点击屏幕时,从相机位置发射一条射线,计算这条射线与场景中所有网格的交点,命中的就是用户点到的物体。Three.js 的Raycaster把这件事封装得很到位:

const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(); renderer.domElement.addEventListener('click', (event) => { mouse.x = (event.clientX / window.innerWidth) * 2 - 1; mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObjects(model.children, true); if (intersects.length > 0) { const obj = intersects[0].object; // 向上查找可交互的父级 let target = obj; while (target.parent && !target.userData.interactive) { target = target.parent; } if (target.userData.interactive) { highlightObject(target); showInfoPanel(target.userData.info); } } });

这里有个细节:intersectObjects的第二个参数设为true才会递归检测子网格。模型加载进来之后,直接model.children通常只能拿到顶层节点,真正的网格可能嵌套了好几层,不递归的话很可能点不到东西。另外,建议给每个可交互部件设置userData,把名称、介绍、链接等信息挂上去,这样后续逻辑清晰很多。

模型拆解是我在工业设备展示里最喜欢的交互。原理并不复杂——每个部件有一个“装配位置”和一个“拆解位置”(一般是沿某个轴偏移一定距离),动画就是在这两个位置间做插值。比如发动机拆解,可以把缸体、活塞、曲轴分别定义拆解方向和距离,点击“爆炸视图”按钮后所有部件同时向外移动,结构关系一目了然。

热点标注则更偏向数字化展厅场景。在每个关键部件位置放一个小标签(HTML 元素或 Sprite),点击标签弹出图文说明。实现上,核心是把 3D 坐标投影到屏幕坐标:

function getScreenPosition(worldPos) { const vector = worldPos.clone().project(camera); return { x: (vector.x * 0.5 + 0.5) * window.innerWidth, y: (-vector.y * 0.5 + 0.5) * window.innerHeight }; }

这个函数会在每次渲染时被调用,保证标签始终跟随模型部件。注意:当部件转到相机背面时,要判断vector.z > 1然后隐藏标签,不然会出现标签倒挂的穿帮效果。

3.4 分享:状态序列化、截图分享与社交链路打通

分享环节是整个流程的收尾,也是很多项目一开始根本没想、最后被客户追着补的功能。其实 3D 展示的分享可以拆成两个层面:状态分享和渠道分享。

状态分享是指把用户当前的视角状态(相机位置、旋转角度、缩放比例)编码到 URL 参数里,别人打开这个链接就能“瞬移”到同一个视角。实现方式很直接:

// 分享时,把相机位置和控制器目标点编码进 URL function getShareUrl() { const pos = camera.position; const target = controls.target; const params = new URLSearchParams({ px: pos.x.toFixed(3), py: pos.y.toFixed(3), pz: pos.z.toFixed(3), tx: target.x.toFixed(3), ty: target.y.toFixed(3), tz: target.z.toFixed(3) }); return window.location.origin + window.location.pathname + '?' + params.toString(); } // 加载时,解析 URL 参数并设置相机 function applyShareParams() { const params = new URLSearchParams(window.location.search); if (params.has('px')) { camera.position.set( parseFloat(params.get('px')), parseFloat(params.get('py')), parseFloat(params.get('pz')) ); controls.target.set( parseFloat(params.get('tx')), parseFloat(params.get('ty')), parseFloat(params.get('tz')) ); } }

渠道分享则是把链接通过微信、微博、邮件等渠道发出去。现在大多数场景是 H5 页面,所以核心是把 URL 生成二维码,或者调起 Web Share API:

if (navigator.share) { navigator.share({ title: '3D 模型展示', text: '来看看这个 3D 模型', url: getShareUrl() }); }

另外,很多运营场景需要一张“封面图”,也就是模型的静态渲染截图。用 Three.js 的renderer.domElement.toDataURL()可以实现:

function captureSnapshot() { renderer.render(scene, camera); const dataURL = renderer.domElement.toDataURL('image/png'); const link = document.createElement('a'); link.download = 'snapshot.png'; link.href = dataURL; link.click(); }

这个小功能看起来不起眼,但在客户提“我想把这个模型发给同事看,但不想下载安装任何东西”的需求时,截图 + 链接一套组合拳能解决大部分沟通问题。

4. 常见问题与排查技巧实录

4.1 模型加载后黑屏,什么也看不见

这个问题我遇到太多次了。排查顺序一般是这样:先看控制台有没有报错,再看模型是否加载成功(loader.load的回调有没有执行),最后检查相机朝向和灯光。

90% 的黑屏原因是灯光没打好或相机在模型内部。尤其是从 Blender 导出的模型,如果没有在 Blender 里添加灯光,整个场景就是全黑的——因为 Three.js 默认没有光源。所以新手最容易犯的错是忘了加灯。另外,检查摄像头位置:如果模型尺寸特别大,而相机初始位置在模型内部,四面都是网格的内表面,自然什么都看不出来。

还有一个隐蔽问题:模型材质使用了MeshStandardMaterial,但没有环境贴图,金属度较高的物体会显得暗黑一片。建议加载一个简单的环境贴图(可以用 Three.js 的RoomEnvironment),或者调高环境光强度。

4.2 模型表面闪烁、破面

闪烁(Z-fighting)是因为两个表面几乎完全重合,深度缓冲区分不出谁前谁后。常见于模型在建模软件里复制了面没有合并,或者地面和模型底座贴在一起。

解决办法很简单:把重合的两个面做一个细微偏移(比如 0.001),或者在 Blender 里用“合并重叠面”功能清理模型。如果闪烁发生在阴影区域,可以调整阴影偏移量:

dirLight.shadow.bias = -0.0005;

4.3 模型加载慢、页面卡顿

性能问题的优化顺序是:先看几何复杂度(顶点数、三角面数),再看材质和贴图,最后看渲染设置。

几何复杂度最简单粗暴的优化就是减面。Blender 里用“Decimate”修改器可以快速减半三角面数,视觉影响通常不大。材质方面,把大尺寸贴图压缩到 1024 或 2048,并确保使用 WebP 格式。渲染设置上,如果不需要透明效果,可以设置renderer.sortObjects = true并按需调整抗锯齿策略。

卡顿最容易被忽视的原因是每帧渲染的像素太多——也就是设备像素比设置太高。renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))这句是保命用的。手机上有些设备 devicePixelRatio 是 3,如果按 3 渲染,GPU 负载是 2 的 2.25 倍,发热降频立刻卡成狗。

4.4 移动端手势冲突

OrbitControls 默认支持触摸手势,单指旋转、双指缩放。但在 H5 页面里,如果外层有滚动容器,单指操作会把页面一起带动,体验很割裂。

我的做法是:全屏展示时阻止页面滚动,并给 canvas 加上touch-action: none样式:

canvas { display: block; touch-action: none; }

这样触摸事件完全交由 Three.js 处理,页面上其他区域正常滑动。如果模型展示只是页面的某个区块,则建议把区块高度设置成接近全屏,并打开controls.enablePan = false,减少手势冲突。

4.5 分享链接参数丢失或被转义

URL 参数在分享过程中被微信等聊天工具截断或转义是常见问题。解决方案是:不要只依赖 URL 参数传递状态,而是做一个短链服务,把完整参数存到后端或数据库,用短码映射。这个方案顺带还能统计分享人数和查看次数,对运营很有价值。

如果不方便做后端,也可以把状态压缩成更短的字符串,比如用encodeURIComponent(btoa(JSON.stringify(state)))再拼到 URL 后面,但要注意长 URL 在部分 App 里会被截断。

5. 经验心得

做完十几个网页 3D 展示项目后,我的感受是:技术上并没有什么高不可攀的壁垒,Three.js 把 WebGL 的门槛已经降得很低,网上资料多到看不完。真正拉开差距的,是流程上的精细度——模型有没有压缩到位、灯光氛围有没有调对、交互反馈是否跟手、分享链路是否顺畅。这些小点单独看都不难,但连在一起决定了用户打开页面后的第一感受。

如果你刚起步,我的建议是:先别追求炫酷的工业级效果,用最简单的场景加载一个 GLB 模型,把相机控制、点击交互、分享链接这三个主线跑通,再做减法优化。一次只解决一个问题,每次改完都要在真机(特别是手机)上测一遍,这样很快就能攒出一套适合自己的模板。

最后分享一个我常用的配置组合:Vite 做工程底座,Three.js 负责渲染,Draco 压缩几何,WebP 压缩纹理,OrbitControls 做浏览,Raycaster 做交互,URL 参数做状态分享。这套组合的代码量不大、维护成本低、扩展性也好,后面想加模型拆解、热点标注、自动旋转、多模型切换,都只需要在现有骨架上加零件,不会推翻重来。希望这篇文章能让你少走弯路,早点把第一个可交互的 3D 模型挂到网上去。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询