我在 WorkBuddy 用 Three.js 把一辆 11MB 的汽车「拆开」塞进浏览器,还顺手治好了 GitHub Pages 在国内的加载顽疾
> 🔥 这是一篇「从 0 到线上可访问」的完整复盘:不只是炫技,更包括了把一个 11MB 大模型搬到国内能流畅打开的真实踩坑。 ------ ## 开场 做一个 3D 汽车拆解页面不难,难的是:**把一辆 11MB 的整车模型,在没有任何后端、纯浏览器环境里跑起来,并且国内用户打开不会卡在白屏。** 这篇记录我如何用 Three.js 做出 CTViewer,以及上线后「效果出不来」那次让我排查到凌晨的事故。  ------ ## 一、作品介绍:CTViewer 是什么 CTViewer 是一款**纯前端的 3D 汽车拆解可视化查看器**。打开网页,你就能把一辆概念车「炸开」成 100+ 个零件,鼠标悬停任意部件高亮,还能实时切换车漆工艺——原厂色、金属、珠光、哑光、糖果漆,一键换色带缓动。 - 在线地址:https://codeganhaoz.github.io/CTViewer/ - 源码仓库:https://github.com/CodeGanHaoZ/CTViewer 它内置了两款真实车型: | 车型 | 拆解粒度 | 网格数 | 材质 | 贴图 | | ----------- | ------------------------------------------ | ------------------ | ---- | ---- | | Concept Car | 零件级(含方向盘/轮辐/踏板/刹车盘/门饰板) | 97 mesh / 109 部件 | 29 | 14 | | Ferrari 458 | 总成级 | 51 网格 | — | — |  💡 **核心观点**:它不依赖任何 CDN——Three.js 运行时和 Draco 解码器全部打进 `vendor/`,断网也能跑。  ------ ## 二、设计思路:从一次「拆相机」演化而来 这个项目的起点不是汽车,而是 **DJI Osmo Action 4 的三维拆解页**。我先在那上面验证了三件事可行:菲涅尔边缘高亮、爆炸/装配动画、Bloom 辉光后期。确认链路跑通后,才把方案迁移到更复杂的汽车模型上。 选型阶段我没有凭感觉挑模型,而是写了脚本**解析 4 个 GLB 的真实内部结构**(三角面 / 节点 / mesh 数量),用数据而非估算,最终选定 Concept(零件足够细,拆解有看点)+ Ferrari(总成干净,对比鲜明)双车方案。 📌 **设计原则**: 1. 零件「扁平化」——把所有 mesh 从原嵌套层级摘出,仅保留世界变换,这样每个零件才能独立沿方向位移。 2. 高亮用「壳层」而非「染色」——避免选中时整车变色。 3. 全离线优先,上线再谈加速。  ------ ## 三、作品核心功能 - **拆解 ↔ 装配动画**:100+ 部件沿「径向 + 分组偏置」爆炸(车身向上、内饰向下、动力向后、轮组沿车轴左右分离),不会糊成一团。 - **单部件拾取**:射线拾取 + 菲涅尔壳层高亮,悬停即亮,点击锁定。 - **实时换漆**:车漆替换为带清漆层的 `MeshPhysicalMaterial`,5 种工艺、460ms 缓动过渡,按车型各存一份不串色。 - **自动构图**:双轴取大,旋转时自动保持整车在视野内。 - **界面**:科技蓝黑主题、主题化滚动条、十余处 CSS 动效(已适配「减少动态效果」系统偏好降级)。  ------ ## 四、技术实现(核心代码) ### 4.1 零件扁平化 + 拆解方向 这段把每个 mesh 从原层级摘出、保留世界变换,并按「部件中心相对车心的放射方向」决定爆炸方向: ```js // 把 mesh 从原层级摘出,保留世界变换(matrixWorld.decompose) const worldMats = meshes.map(m => m.matrixWorld.clone()); meshes.forEach((m, i) => { group.add(m); worldMats[i].decompose(m.position, m.quaternion, m.scale); }); // 拆解方向:部件包围盒中心相对车心的放射方向(group 局部空间) const wc = group.worldToLocal(wb.getCenter(new THREE.Vector3())); const radial = wc.clone().sub(carCenter).normalize(); // 行走系统沿车轴左右分离,其余按分组偏置叠加 if (gid === 'wheel' || gid === 'axle') { const sx = Math.sign(radial.x) || 1; dir = new THREE.Vector3(sx, gid === 'axle' ? -1.15 : 0.1, 0).normalize(); } else { const b = GROUP_BIAS[gid]; dir = b ? radial.clone().add(b).normalize() : radial.clone(); } ``` > 这段做什么:决定「零件往哪飞」。放射方向保证零件从车心向外散开,分组偏置(`GROUP_BIAS`)再给车身/内饰/动力各自一个主方向,避免所有零件朝同一处挤。 ### 4.2 菲涅尔高亮壳层(不染色) 选中部件时,我没有去改材质颜色(那会整车变色),而是给 mesh 套一层**略放大的透明壳**,用菲涅尔(Fresnel,即「视角越斜、边缘越亮」的光线效应)做边缘发光: ```glsl // 片元着色器核心:ndv 越小(越接近边缘)rim 越大 float ndv = clamp(dot(normalize(vN), normalize(vV)), 0.0, 1.0); float rim = smoothstep(0.66, 0.02, ndv); float a = pow(rim, uPower * 0.45) * uIntensity; gl_FragColor = vec4(uColor * a, a); ``` ```js const shell = new THREE.Mesh(mesh.geometry, m); shell.scale.setScalar(1.022); // 比本体大 2.2%,形成描边 shell.renderOrder = 2; mesh.add(shell); // 跟随本体,无需单独管理 ``` > 这段做什么:用一层放大 2.2% 的透明壳,只对「边缘」上光,实现干净的高亮描边,且不影响本体材质和车漆。 ### 4.3 实时换漆的缓动过渡 换漆不是「瞬间跳色」,而是 460ms 的 `easeInOutQuad` 插值颜色、金属度、粗糙度、清漆层: ```js const step = now => { const k = Math.min(1, (now - t0) / ms); const p = k 这段做什么:`WeakMap` 存每材质的 rAF 句柄,连续点色块时取消上一帧,避免过渡叠加闪烁。 ### 4.4 轮毂「白盘子」事故与修复 这是整个项目最隐蔽的坑。原模型轮圈打开是一片**自发光白盘**,改底色、改粗糙度统统无效。 根因:GLB 里轮圈材质带 `emissiveFactor=[1,1,1]` + 一张灯光遮罩贴图,UV 落在亮区 → 轮圈变成自发光白盘;更坑的是**主循环每帧 `emissive.copy(m.base)` 把白色写回**,只改材质不改基线会被立刻覆盖。 ```js if (t.killEmissive && mm.emissive) { mm.emissive.setRGB(0, 0, 0); // 清零自发光 if (mm.emissiveMap) mm.emissiveMap = null; // 摘掉遮罩贴图 } // 同时把高亮基线压黑,否则选中时轮辐不发光 p.mats.forEach(e => { if (killed.has(e.m)) e.base.setRGB(0,0,0); }); ``` 修复后:黑轮辐(`0x2b323c` 拉丝金属)+ 银色中心盖(`0x9ba7b8`),任何角度都是深色轮圈。 ------ ## 五、上线踩坑:11MB 模型在 GitHub Pages 上「效果出不来」 ⚠️ **这是全文最值得看的一段。** 本地一切正常,部署到 GitHub Pages 后用户反馈「页面出来了,但车不出来」。 我做了三件事定位: 1. 用无头浏览器直连线上抓网络 —— **全程零报错、零失败请求**,`ferrari.glb` 根本没被请求。 2. 实测下载速度 —— Concept 11.2MB 在 GitHub Pages 上只有约 **48KB/s,243 秒才下到 113% 仍未完成**。 3. 进度显示「132%」是假象 —— GitHub Pages 启用了 gzip,`loaded/total` 因压缩比算出了超过 100%。 **根因**:页面 `boot()` 是「等两车都加载完才显示」,11MB 大文件在国内慢速链路下永久阻塞,整页卡在遮罩。 我用了三招解决: 🔥 **第一招 · Draco 几何压缩** 体积构成是「几何 8.27MB + 贴图 2.79MB」,该压的是几何。用 `@gltf-transform` 压缩,**11.23MB → 4.00MB(压掉 64%)**,节点/材质/网格数量完全不变,并保留 6 个 KHR 扩展(含清漆层)。 🔥 **第二招 · 资源走 jsDelivr** 线上优先走 `cdn.jsdelivr.net/gh/...@main/`(带 CORS,实测比 Pages 快 **7–9 倍**,4MB 约 14 秒),GitHub Pages 作为回退源: ```js const onPages = /\.github\.io$/.test(location.hostname); const CDN = 'https://cdn.jsdelivr.net/gh/CodeGanHaoZ/CTViewer@main/'; const start = url => loader.load(url, onLoaded, onProgress, err => { if (onPages && !fellBack) { fellBack = true; start('./' + def.file); return; } onError(err); }); start(onPages ? CDN + def.file : './' + def.file); ``` 🔥 **第三招 · 渐进式加载** 先只加载主车型立即出画面,其余车型**后台补载**后再点亮 tab;进度封顶 100% + 显示 MB;超过 25 秒提示「网络较慢」;失败给「点击重试」。 ```js await loadCar(preferred); // 先出主车,尽快隐藏遮罩 // ... 初始化界面、动画、构图 ... CAR_ORDER.filter(k => k !== current && !CARS[k]) .forEach(k => loadCar(k).then(c => { if (c) updateTabCounts(); })); // 后台补载 ``` 修复后线上实测:主页面正常渲染蓝漆/黑轮毂/109 部件;30 秒后两个 tab 计数均为 `109 / 51`,Ferrari 后台加载与切换正常,控制台无报错。 ------ ## 六、如何体验与源码 - 在线:https://codeganhaoz.github.io/CTViewer/ - 本地:`python -m http.server 8123` 后访问 `car-exploded.html`(注意不能用 `file://` 直接打开,ES Module 会被浏览器拦截) - 技术栈:Three.js r0.169(ESM + importmap)/ GLTF + DRACO / OrbitControls / EffectComposer + UnrealBloom / RoomEnvironment / MeshPhysicalMaterial(清漆层) ------ ## 编辑手记 「效果出不来」这种问题,本地永远复现不了——它只在「大文件 + 弱网 + 串行阻塞」的叠加条件下才暴露。这次的经验可以压缩成一句话:**前端性能问题,先看传输层,再看渲染层;模型太大就压几何,CDN 慢就换镜像,阻塞就拆串行。** 你做 3D / 可视化项目时,遇到过哪些「本地好好的、一上线就翻车」的坑?欢迎在评论区聊聊,下期我可以专门写一篇「WebGL 上线的 10 个隐形坑」。