上个月一个做文创的朋友找我,说想在官网上放一个产品3D展示,用户可以在手机里转着看细节,最好还能点某个部位弹出讲解,看完能顺手转发给微信好友。他一开始以为这活儿要么做成交互视频,要么得上一套客户端,听我说一个普通网页、一套开源的 three.js 渲染库、几十行 JS 就能跑起来后,第一反应是:那会不会很卡?会,前提是你把工作流搞错了;不会,前提是你把模型优化、渲染设置和交互实现当成一条完整管线来看,而不是网上找一段代码贴上去就完事。
这篇文章我打算按自己从评估需求到上线的思考顺序来写:什么时候真需要做网页3D展示、glTF 格式和压缩为什么要前置处理、Three.js 里从空场景到可交互模型的最小代码怎么写、访客看完之后怎么把模型分享出去,以及我在真实项目里栽过的几个跟头。无论你是前端想接第一个3D需求,还是运营、产品想找技术团队聊需求,都能从里面挑出可以直接照做的部分。
1. 为什么我会劝你先想清楚“图、视频、3D”谁够用
1.1 三种载体各擅长什么,决定你要不要上3D
拿到需求第一步不是装环境,而是回答一个问题:这个展示是不是真的需要用户主动控制视角。如果是卖一张海报,你上 3D 意义不大;如果是卖椅子、电饭煲、鞋子、机械设备,那用户想看的往往不是正面一张图,而是转过来看背面、卡扣、走线、底部铭牌。图文能表达信息,视频能交代动态,只有在用户需要自己选择视角、自己探索结构细节时,3D 才不可替代。
我习惯把不同载体的特性摆成一张表再来判断:
| 形式 | 用户能做什么 | 信息密度 | 制作维护成本 | 网络加载体感 |
|---|---|---|---|---|
| 图文 | 看、读、局部放大 | 低 | 低 | 最快 |
| 视频 | 跟着镜头看,无法控制视角 | 中 | 中 | 中等,受时长影响 |
| 360° 序列帧 | 左右拖拽,视角有限 | 中 | 中高,要出多帧图 | 中 |
| WebGL 3D | 自由旋转、缩放、点选、换肤 | 高 | 建模 + 前端开发 | 优化后可以很轻 |
在这个阶段,你真正要评估的不是“3D有多酷”,而是“用户有没有必要主动转这个模型”。做小家电详情页,3D 能带来明显的新鲜感和停留时长;做一张静态宣传海报,强行上模型反而显得刻意。
1.2 什么情况下用 3D 反而是负优化
我也见过不少上到一半后悔的项目,大致集中在几个场景:
- 产品外形本身很平面,用户没有多角度观察的诉求;
- 目标用户大量集中在几年前的入门机型,或者弱网环境,加载一个三四十 MB 的模型只会劝退;
- 模型还在频繁改版,却没有一套“前端自动读取最新模型”的流程,每次换个模型都要重新发版;
- 手里只有网上扒来的模型,版权不清楚,贴图和布线也不适合实时渲染。
遇到这些情况我通常会劝对方缓一缓,不是说 3D 不行,而是当下需求不需要付出这个成本。真正落地 3D 展示的项目,前期往往已经积累了比较完整的模型资产,比如工业设计文件、电商产品的 C4D/Blender 工程,或者原来做 AR 用的模型。
2. 技术栈选型:从 model-viewer 到 Three.js,别一上来就上“重型框架”
2.1 我用过的几类实现方案和各自脾气
技术选型这件事,其实不需要几千字的前期论证。我一般先画一个判断:这个项目是要“快速出货”还是“深度定制”。是前者,优先选开箱即用的组件;是后者,才需要把 three.js 这类引擎铺开。
下面是几类主流路线,每种我都实际用过或者至少做过评估:
| 路线 | 入口成本 | 定制能力 | 适用场景 | 备注 |
|---|---|---|---|---|
| 原生 WebGL | 极高 | 最高 | 自研渲染器、写特殊 Shader | 绝大多数项目不需要 |
| Three.js | 中 | 高 | 产品展示、数字孪生、轻量游戏 | 生态最丰富,参考案例最多 |
| Babylon.js | 中高 | 高 | 工程可视化、复杂游戏 | 自带调试面板,上手比 three.js 陡一点 |
<model-viewer> | 很低 | 中 | 电商、新媒体展示 | 不用写 JS 就能手势控制 |
| A-Frame | 低 | 中 | VR 场景 | 底层还是 three.js,封装成 ECS 组件 |
| Sketchfab 等平台嵌入 | 极低 | 低 | 临时展示、个人作品集 | 有平台 logo 和访问限制 |
如果让我一句话推荐:没有强定制需求,先用<model-viewer>;要做热点、换材质、AR、自定义场景,就用 Three.js。大多数动态产品站、数字孪生类项目都会落到 Three.js 上,因为它处在“自由度”和“开发成本”之间最舒服的位置。
2.2 我会先用 model-viewer 验证交互,再决定要不要重写
这里的顺序很多人搞反了。需求是商品详情页摆一个模型,能旋转、缩放、自动旋转,最好还能 AR,其实<model-viewer>一个 Web Component 全搞定了,你只需要几行 HTML。底层用的还是 three.js 的加载和渲染逻辑,只是包了一层标准组件,连控制器灵敏度、阴影、环境光照这些细节都给了默认值。
<script type="module" src="https://ajax.googleapis.com/ajax/libs/model-viewer/4.0.0/model-viewer.min.js"></script> <model-viewer src="/models/cup.glb" camera-controls auto-rotate ar ar-modes="webxr scene-viewer quick-look" style="width: 100%; height: 480px;" ></model-viewer>这段代码放到本地起个静态服务就能跑,手机访问可以通过 AR 按钮把模型投射到真实空间。先用它给业务快速出个可点的原型,比写几十行代码再去对齐需求快很多。但注意,这个组件用真机调试 AR 时必须在 HTTPS 或 localhost 环境,普通 HTTP 页面是弹不出 AR 的。
等到业务方开始提“点击某个零件弹说明书”“给模型换三种配色”“播放打开盖子的动画”这类需求,<model-viewer>就有点施展不开了。它本质上是一个封装好的黑盒,Element 层面只暴露了有限的属性和事件,你想拿到内部场景里的某个 Mesh 去改材质,还是要跳出它换 three.js。
2.3 框架只是工具,别被旧版示例带偏
Three.js 版本迭代很快,搜索出来的结果经常是两三年前的。你照着五年前的示例写,很可能会遇到THREE.Geometry已经没了、THREE.Face3直接报错这类问题。我建议不管用哪个版本,都以当前 npm 包对应的官方文档和 example 为准。文章后面的示例代码基于 r161 左右的版本,核心概念变化不大,但 API 细节可能有微调,跑之前先看控制台报错。
3. 别跳过模型加工:glTF 格式、压缩管线与性能预算
3.1 glTF 之于 3D,就像 JPEG 之于图片
很多第一次做网页 3D 的人容易犯一个错:建模软件里导出一个 OBJ 或者 FBX,就直接丢给前端,然后发现要么加载慢,要么浏览器不识别。网页实时渲染最稳定的格式是 glTF 2.0,单文件版叫 GLB。它不是“最新潮的格式”,而是专门为运行时设计的:用 JSON 描述场景层级,用二进制存网格数据,材质走 PBR 规范,动画、骨骼、节点关系都能保留。
你可以把 glTF 理解成 3D 内容的 JPEG。设计师交付原文件(C4D/Blender/3ds Max),网页端接收的是经过压缩、没有多余软件信息的“成品”。浏览器解析它比解析 OBJ 这种文本格式快得多,还方便配合 Draco、Meshopt 这样的压缩算法进一步减重。
3.2 DCC 导出到 glTF/GLB 时的关键选项
不管你是从 Blender 还是别的软件导出,以下几点统一检查一遍,能省下后面大量调效时间:
- 单位统一。建模软件里一米等于一格,Blender 导出时保持默认米制,避免到网页端后模型突然变成 0.001 倍大小。
- 应用变换。缩放要“应用”,旋转要“应用”,坐标轴最好保持 Y 轴向上。我看到过很多模型加载后躺在地上,就是因为建模时没有应用旋转。
- 顶点数可控。建模软件里为了平滑,一个曲面可能切成上百万三角面。网页上超过几十万顶点就开始吃力,优先用 Decimate 或减面工具清理不可见结构。
- 贴图尺寸收敛。4K 贴图在建模渲染里很常见,但网页上单张 2048 已经很大,很多局部特写用 1024 就足够。
- 命名规范。导出后节点名字会变成你能拿到的唯一标识,前端要做“点击某个零件”就靠这个 name,导成 Mesh_001、Mesh_002 会让你后期想哭。
Blender 里导出就是File > Export > glTF 2.0,勾选GLB格式,导出选项里可以开启 Draco 压缩。注意,默认被选中的物体才导出,提前把要导出的模型选中,避免导出一堆灯光相机和辅助器。
3.3 批量优化道具:gltf-transform 和 gltfpack
如果你的项目经常要处理多个模型,手动在 GUI 里导出太慢了。我常用命令行工具@gltf-transform/cli做加工,它的逻辑很直白:输入一个 glTF/GLB,输出一个处理完的 GLB。
# 先检查模型里有什么,别盲压 gltf-transform inspect model.glb # 一次做完:重排缓冲、压缩网格、压缩纹理 gltf-transform optimize model.glb model-optimized.glb --compress draco --texture-compress ktx2Draco 用来压缩顶点位置、法线、UV 这些几何数据,KTX2 用来压缩贴图。有些时候单独用 Draco 就已经能把模型体积砍掉一半,再把贴图压成 KTX2,一个十几 MB 的原始导出经常能压到 2~3 MB。如果你的纹理已经处理得很干净,甚至不需要 KTX2,普通 JPEG/WebP 转进去也行。
另一个工具是gltfpack,它和 gltf-transform 风格不同,但也能做类似事情:
gltfpack -i model.gltf -o model-packed.glb -cc -tc参数含义以你安装的版本为准,跑之前先看--help。这些命令的核心逻辑是一致的:先压缩,再检查体积,最后真机测试。压缩不是没有代价,Draco 解压要消耗 CPU,这节我在后面“避坑”部分再展开讲。
3.4 我给模型定性能预算的习惯
做展示型页面,我习惯先给模型设一个心理预算,不至于等上线了才被卡顿打脸。这个数字不用当硬性考试分数线,但可以作为工作流的底线:
| 指标 | 桌面端参考 | 移动端参考 |
|---|---|---|
| 顶点数 | 60 万以内,极限 100 万 | 10 万~30 万 |
| 物体数量 / 渲染批次 | 单体模型 < 100,场景 < 300 | 单体模型 < 50,场景 < 100 |
| 贴图尺寸 | 单张 < 2048 | 单张 < 1024,能用 KTX2 更好 |
| 加载体积 | 3~8 MB | 1~3 MB |
| 帧率 | 60fps | 30fps 保底 |
如果你的产品本身就是工业设备,多边形数量很高,那就不要硬削到万级,而是上 LOD:远处显示低模,镜头拉近了替换成高模。这比把所有模型都压成高精度更合理,也是网页 3D 的常态做法。
4. 把 GLB 真正放到页面上:Three.js 最小可运行工程拆解
4.1 初始化项目,能跑就行
最快的启动方式是用 Vite 建一个 vanilla 项目:
npm create vite@latest demo-3d -- --template vanilla cd demo-3d npm install three npm run dev如果你不想搞构建工具,也可以直接在 HTML 里用 import map:
<script type="importmap"> { "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.161.0/build/three.module.js", "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.161.0/examples/jsm/" } } </script> <script type="module" src="/src/main.js"></script>import map 的好处是演示项目不用装 npm 依赖,坏处是你得固定一个版本号,别写 latest,否则哪天官方更新把路径改了你都不知道。
4.2 渲染器、场景、相机,一个都不能少
Three.js 最基础的概念就三样:渲染器把内容画到 canvas,场景装着所有物体,相机决定从哪个角度看。初始化代码我通常会加上颜色空间和 tone mapping,这样加载出来的 PBR 材质在屏幕上的观感更接近建模软件。
import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; const canvas = document.getElementById('app'); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(canvas.clientWidth, canvas.clientHeight); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera( 45, canvas.clientWidth / canvas.clientHeight, 0.1, 1000 ); camera.position.set(4, 2.5, 5); const controls = new OrbitControls(camera, canvas); controls.target.set(0, 0.8, 0); controls.enableDamping = true;这里有两个容易忽略的点:setPixelRatio(Math.min(devicePixelRatio, 2))是为了防止 iPhone 这种高 DPR 设备上 GPU 压力过大,很多人觉得移动端卡,其实 DPR 没限制是原因之一;outputColorSpace和toneMapping则是让明暗过渡更自然,白天照进来不会生硬过曝。
4.3 加载 GLB 并调整位置
GLTFLoader 是加载 GLB 的最常用入口。加载成功后拿到的gltf.scene就是一个普通的 Object3D,你可以直接scene.add(gltf.scene)。但建模软件里的原点经常不在模型底部,所以我习惯加载后立刻做一次归位:
const loader = new GLTFLoader(); loader.load('/models/cup.glb', (gltf) => { const model = gltf.scene; scene.add(model); // 把模型中心挪到原点 const box = new THREE.Box3().setFromObject(model); const center = box.getCenter(new THREE.Vector3()); model.position.sub(center); // 再让模型底部贴到 y=0 box.setFromObject(model); const minY = box.min.y; model.position.y -= minY; controls.target.set(0, 0, 0); controls.update(); }, undefined, (err) => { console.error('模型加载失败', err); });如果你的模型加载后没有在画面正中央,大概率就是这个地方出了问题。固定归位逻辑还有个额外好处:后续做热点标注时,坐标计算不会因为模型偏移而乱掉。
4.4 动画循环和窗口尺寸变化
Three.js 的渲染是一个持续循环,每一帧都要调用一次renderer.render(scene, camera)。我倾向于用renderer.setAnimationLoop而不是自己写requestAnimationFrame,因为前者在启用 WebXR 时能无缝切换,代码也更干净。
function render() { controls.update(); renderer.render(scene, camera); } renderer.setAnimationLoop(render); window.addEventListener('resize', () => { camera.aspect = canvas.clientWidth / canvas.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(canvas.clientWidth, canvas.clientHeight); });如果这个页面是后台管理工具或者常驻页面,可以在用户不操作时暂停渲染,减少 CPU/GPU 占用。但展示型页面一般开着自动旋转或者等待用户转动,持续渲染也还好。
4.5 PBR 材质没有被点亮,模型就会黑成一块碳
你可能会遇到一种情况:模型加载成功了,但看起来特别暗,金属部分像黑色塑料,还有一种“脏脏的”感觉。这往往不是模型的问题,而是缺少环境贴图。
PBR 材质里的金属度和粗糙度需要依赖环境光照来计算反射。只有一个方向光和环境光,金属材质没有反射信息来源,自然就黑掉了。Three.js 提供了RoomEnvironment,用简单房间环境生成一张环境贴图,在开发阶段拿来点亮模型非常方便:
const pmrem = new THREE.PMREMGenerator(renderer); scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;加上这行,模型的金属反光、粗糙面细节都会立刻立起来。生产项目里可以换成 HDR 环境,或者直接用相机拍一张环境贴图,效果会更符合你想要的氛围。
5. 从“转着看”到“点着玩”:命中拾取、动画和自定义交互
5.1 点击模型任意部位,用什么原理
光能转能缩还不够,交互才是 3D 展示区别于视频的关键。最常见的交互是“点击模型的某个部位,弹出对应信息”。原理是 Raycaster:从相机位置,穿过鼠标点击的屏幕坐标,向场景发射一条射线,检测它与哪些三角面相交。
const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); function onPointerDown(event) { const rect = renderer.domElement.getBoundingClientRect(); pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(pointer, camera); const hits = raycaster.intersectObjects(model.children, true); const data = hits.length ? findTaggedParent(hits[0].object) : null; if (data) showPopup(data); } function findTaggedParent(obj) { let cur = obj; while (cur) { if (cur.userData && cur.userData.tag) return cur.userData; cur = cur.parent; } return null; } renderer.domElement.addEventListener('pointerdown', onPointerDown);这段代码里有个很实用的技巧:不要只判断hits[0].object.name。GLB 内部往往按材质或蒙皮拆分成很多个 Mesh,单个 Mesh 的名字是系统生成的,真正承载语义的是某个父级节点。所以从命中的 Mesh 一路往上找,直到找到一个带userData.tag的节点,再拿这个 tag 去对应产品说明。
5.2 热点标注的两种实现方式
热点标注是产品展示的高频需求,比如在瓶身上标一个“食品级材质”,点一下浮出说明。做法基本分两类。
一是 3D 世界坐标投射到屏幕坐标,再用 HTML 绝对定位覆盖在 canvas 上方:
function updateHotspotWorld(marker3D, domEl) { const v = new THREE.Vector3(); marker3D.getWorldPosition(v); v.project(camera); const x = (v.x * 0.5 + 0.5) * canvas.clientWidth; const y = (-v.y * 0.5 + 0.5) * canvas.clientHeight; domEl.style.transform = `translate(${x}px, ${y}px)`; }这个函数要在每一帧渲染时调用,或者至少在相机变化时调用,否则模型一转,DOM 浮层还停留在原地。好处是浮层可以随意写 HTML,卡片、图片、按钮都能用,UI 美观程度上限很高。
二是直接用 CSS2DRenderer,这类 addon 的本质也是把 DOM 元素投射到世界坐标,但封装得更好。它适合数量多、位置相对固定的标注。如果你要灵活控制浮层样式和动画,还是自己写投影更顺手。
5.3 播放模型动画:AnimationMixer 的基本用法
带骨骼动画的模型在网页上播放,用的主要是AnimationMixer。加载完成之后,把 GLB 里第一段动画找出来,绑到 mixer 上,不断更新 mixer 的进度,就能让动画跑起来。
let mixer; const clock = new THREE.Clock(); loader.load('/models/robot.glb', (gltf) => { scene.add(gltf.scene); mixer = new THREE.AnimationMixer(gltf.scene); const clip = THREE.AnimationClip.findByName(gltf.animations, 'Wave'); if (clip) mixer.clipAction(clip).play(); }); function render() { const delta = clock.getDelta(); if (mixer) mixer.update(delta); controls.update(); renderer.render(scene, camera); }动画名在建模软件里有时候会带奇怪的命名空间,或者多段动画被合并成了一段。拿不到对应名字时,建议把gltf.animations打印出来看看数组结构,再决定播放哪一段。
5.4 换材质和换颜色,是电商配置器的基础
要让用户自己挑颜色,最直接的做法是遍历模型的所有 Mesh,把材质颜色替换掉:
model.traverse((child) => { if (child.isMesh) { child.material.color.set('#f5c518'); } });这种做法有个陷阱:如果多个 Mesh 共用了同一个材质实例,改一次所有共享 Mesh 都会变。通常你需要先找到“外壳”这层,单独clone()一份材质,再改颜色,避免把内部零件也染了。建模阶段给节点起好名字、或者导出时用userData标记零件类型,能让你少写很多条件判断。
5.5 还能玩的进阶方向
模型展示做到底,还能接 WebXR 做真机 AR 预览。Three.js 里启用renderer.xr.enabled = true,再用示例里的 ARButton 就能进入增强现实模式。做产品拆解动画时,可以先获取每个零件的本地坐标,让它们沿着自身到原点的方向线性外推,产生爆炸视图。想做物理拖拽,套一个 Cannon-es 就能让零件掉落到桌面上。这些玩法不是每个项目都要用,但底层能力都在那里,等需求来了再研究也不迟。
6. 让访客把模型带走:链接分享、文件下载与嵌入方案
6.1 把当前视角写进 URL,打开链接看到同一画面
分享的本质是“把当前状态传递给另一个人”。最简单的做法是把相机的位置、朝向、模型 ID 写进 query 参数,别人打开链接后,初始化阶段依次读出来,恢复到这个视角。
function buildShareUrl(modelId, controls) { const params = new URLSearchParams(); params.set('model', modelId); params.set('pos', controls.object.position.toArray().join(',')); params.set('target', controls.target.toArray().join(',')); params.set('autoRotate', controls.autoRotate ? '1' : '0'); return location.origin + location.pathname + '?' + params.toString(); }在页面加载时读取参数,设置 camera 和 controls target。这里有个细节:只保存相机位置不够,还应该保存 target,否则用户旋转过相机后,聚焦点还是原点,视角会变得很怪。
const sp = new URLSearchParams(location.search); const pos = (sp.get('pos') || '4,2.5,5').split(',').map(Number); const target = (sp.get('target') || '0,0,0').split(',').map(Number); camera.position.set(...pos); controls.target.set(...target); controls.update();如果你的分享链接需要鉴权,模型 ID 不要用纯自增数字,容易被人遍历。用一个 UUID 或者带上签名参数,并在服务端校验来源。
6.2 分享到社交平台:Web Share API 和 Open Graph
手机端分享,最推荐 Web Share API,它能让系统弹出原生分享面板,用户直接发给微信、钉钉、系统短信,完全不脱离页面。
async function sharePage(shareUrl) { if (navigator.share) { try { await navigator.share({ title: document.title, text: '看看这个3D模型', url: shareUrl }); } catch (err) { // 用户取消分享 } return; } await navigator.clipboard.writeText(shareUrl); // 提示用户链接已复制 }但有一个前提:Web Share API 只能在 HTTPS 或 localhost 环境下使用,并且必须由用户手势触发,不要在页面加载后自动调用。
分享到微信、微博这些渠道时,卡片预览会使用抓取器拿到的 meta 标签,抓取器不会执行你的 JS,所以标题、描述、缩略图必须在服务端渲染的 HTML 里就存在:
<meta property="og:title" content="某产品 3D 展示" /> <meta property="og:description" content="在网页里查看产品细节,支持旋转缩放" /> <meta property="og:image" content="https://your.site/static/preview.png" /> <meta property="og:url" content="https://your.site/view?model=cup" />og:image建议用模型渲染的静态效果图。常见做法是上传模型后在服务端用 Blender 或 Three.js headless 预渲染一张 PNG,也可以直接调建模软件出一张渲染图。
6.3 在浏览器里导出 GLB,让用户带走
有的场景里,访客看完后想把模型下载下来,拿去二次编辑、3D 打印,或者存进自己的素材库。Three.js 示例里的GLTFExporter可以直接把当前场景导出成 GLB:
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js'; function exportGLB(model) { const exporter = new GLTFExporter(); exporter.parse( model, (result) => { const blob = result instanceof ArrayBuffer ? new Blob([result], { type: 'model/gltf-binary' }) : new Blob([JSON.stringify(result)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'model.glb'; a.click(); URL.revokeObjectURL(a.href); }, (err) => console.error(err), { binary: true } ); }注意传递的是你加载后做过材质修改的model,而不是整个 scene。整棵 scene 里还挂着一堆灯光和控制相关的对象,导出去反而污染了模型数据。如果你改过材质颜色,导出的 GLB 会保留这些改动,算是一个很自然的“分享当前配色”能力。
6.4 嵌入到别的页面:iframe 和 postMessage
你的模型展示做得再好,别人不一定愿意跳到你的站点里看。提供一个 iframe 嵌入片段,能显著提升传播率。常见的做法是给链接加一个embed=1参数,嵌入模式下隐藏页头页脚,只保留 canvas。
<iframe src="https://your.site/view?model=cup&embed=1" style="width: 100%; height: 480px; border: 0; border-radius: 12px" ></iframe>如果宿主网站想跟模型页面通信,比如“点按钮让模型换个颜色”,可以在 iframe 内部监听message事件:
window.addEventListener('message', (e) => { switch (e.data?.type) { case 'setColor': // 根据 e.data.color 改材质颜色 break; case 'turnTo': // 根据 e.data.angle 旋转相机 break; case 'playAnimation': // 触发动画播放 break; } });这个方案适合服务商给客户做白标产品,客户不需要理解 Three.js,只要在自己的后台页面嵌入 iframe,再用 postMessage 发指令就能控制模型。
7. 我踩过且希望你避开的几个坑
7.1 渲染效果差的头号原因:没有环境贴图,只有一盏灯
这个坑我重复踩过两次。第一次是把产品模型放进场景,加了环境光和方向光,觉得效果还行,直到客户发来一张建模软件截图,说“为什么网页里的塑料质感完全没有了”。原因就是没有环境贴图,材质没有反射信息,所有金属质感和明暗渐变都丢了。加上 RoomEnvironment 之后,效果立刻提升了一个档次。这里要特别强调:环境贴图不是“美化选项”,而是 PBR 材质正常工作的基础。
7.2 阴影是“帧率杀手”
阴影很吃 GPU。开了阴影以后,每个能投射阴影的灯光都要额外渲染一张深度图,场景里的阴影接收物体越多,开销越大。移动端上动不动就掉到 20 帧以下。我的建议是:能不开就不开,确需开启时,用renderer.shadowMap.enabled和renderer.shadowMap.type = THREE.PCFSoftShadowMap,同时限制 shadow camera 的范围,别让整个场景都处在阴影计算范围内。如果产品本身很小,也可以用一张假的“阴影贴图”或者地面阴影平面来模拟。
7.3 Draco 压缩后的解压卡顿,比网络体积更隐蔽
Draco 压缩能显著减小体积,但解压过程是纯 CPU 计算。某个中端安卓机上,一个 3.5 MB 的 Draco GLB 加载后出现了差不多一秒的明显卡顿,用户以为页面死了。后来我用未压缩的版本对比,发现网络时间省了,CPU 解压时间反而更让人难受。最终方案是给低端机型走未压缩的低模版本,高端机用完整压缩版。真机测试里,特别是两年前的手机,一定要模拟弱网和低端 CPU 两种环境,不要只看自己办公电脑上的加载速度。
7.4 SPA 页面切走模型,内存不会自动释放
如果你的项目是后台系统,用户可能从“3D 展示页”切到别的模块,再切回来。如果只调用了scene.remove(model),内存里的几何体、纹理、材质全都还在,每次切换都新建一份,页面会越来越卡。正确做法是把模型里每个 Mesh 的 geometry 和 material 显式 dispose:
function disposeModel(model) { model.traverse((child) => { if (!child.isMesh) return; child.geometry.dispose(); const materials = Array.isArray(child.material) ? child.material : [child.material]; materials.forEach((mat) => { for (const key in mat) { if (mat[key] && mat[key].isTexture) mat[key].dispose(); } mat.dispose(); }); }); scene.remove(model); }同时还要清掉事件监听器,如果用了 OrbitControls,记得controls.dispose(),否则 canvas 的监听器也一直挂在内存里。
7.5 低端移动端需要一套“低配通道”
加载模型之前,可以根据网络和硬件情况做降级:
- 用
navigator.connection.effectiveType判断网络是 3G 还是 4G; - 用
navigator.deviceMemory和navigator.hardwareConcurrency判断设备强弱; - 通过
window.devicePixelRatio决定渲染分辨率上限; - 准备一套无 Draco 或者低面数的 fallback 模型,弱网时直接加载。
这套逻辑写起来不复杂,但对实际体感提升巨大。我见过很多项目在 iOS 旗舰机上跑得很流畅,一到千元机就崩,原因就是没有做降级。
7.6 别忘了 prefers-reduced-motion
如果你默认开启自动旋转,有些用户在系统里设置了“减少动态效果”,会比较反感页面自动动来动去。CSS 里有prefers-reduced-motion媒体查询,JS 里可以用matchMedia('(prefers-reduced-motion: reduce)')检测,检测到时关掉 autoRotate。这个细节不会花你太多时间,但很能体现产品用心程度。
最后说下我现在做这类项目固定的流程,相当于给你一张能直接照抄的路线图:第一步,先拿原始模型导出 GLB,用最简单的 model-viewer 或一个 three.js 页面跑起来,确认渲染观感和基础交互;第二步,用 gltf-transform 或 gltfpack 压缩一遍,记录体积,再找一台旧手机实测帧率;第三步,根据业务需求决定热点用 DOM 浮层还是 CSS2D,把点击、换色、动画接上去;第四步,把 URL 分享、下载、iframe 嵌入三个按钮补齐;第五步,上线前模拟弱网和低端机,卡顿就回头改模型,而不是只调代码。
这套流程已经帮我顺利完成了好几个从 0 到 1 的展示型项目。做网页 3D 看着门槛高,实际拆开后就是模型加工、渲染场景和交互事件三件事。技术选型会更新,API 会变化,但“先把模型减重,再把场景跑通,最后再谈玩法”这个顺序,无论换什么引擎都不会过时。