用Three.js打造轻量3D作品集:5MB体积控制实战指南

📅 2026/8/27 7:34:33
用Three.js打造轻量3D作品集:5MB体积控制实战指南
最近 Hacker News 上出现了一个让我刷到之后反复回看的帖子有位开发者用两年半时间迭代自己的 3D 作品集网站最终让整个站点的资源体积保持在 5MB 以内。在 3D 场景、交互动画、个人作品展示这些需求叠加在一起的时候还能把体积压到这个量级确实值得认真拆解一下。这篇文章不打算去复刻那个网站的具体实现因为它的源码并非公开。我更想从工程角度把“用 Three.js 做 3D 个人站并控制在 5MB 以内”这件事讲清楚从 3D 场景怎么搭、模型怎么压缩、纹理怎么处理、代码怎么分包到移动端性能怎么优化、常见报错怎么排查。读完你会有能力自己从零做一个同样轻量的 3D 作品集网站。1. 3D 作品集网站到底要解决什么问题1.1 为什么个人站要上 3D个人作品集网站本质上是一个“数字名片 作品展示 简历替代”的结合体。传统的个人站大多是图文排版信息完整但缺少记忆点而 3D 作品集网站可以在用户进入页面的前几秒就形成视觉冲击把“这个开发者或设计师的作品风格”用更直观的方式传递出来。常见的方向有几种全屏 3D 场景进入页面后用户面对一个可旋转、可交互的 3D 空间场景中陈列自己的代表作品。3D 模型展示针对做 3D 建模、游戏美术、工业设计的人来说直接在网页里展示 GLB/GLTF 模型比静态截图有说服力得多。3D 数据可视化把个人技能、项目数据做成柱状图、粒子云、地球动画等。混合式页面首屏用 3D 背景下面保留传统的内容区块兼顾表达与可读性。从技术实现上看目前 Web 端 3D 的主力方案仍是 WebGLThree.js 则是社区最成熟、资料最全的封装库。无论你最终选什么框架核心概念都绕不开场景Scene、相机Camera、渲染器Renderer、灯光Light、几何体Geometry和材质Material。1.2 为什么体积要控制在 5MB 以内很多第一次做 3D 页面的同学很容易把资源体积做成几十甚至上百 MB。原因是随手导出一个高模 FBX再贴几张 4K 贴图体积就爆了。而“5MB 以内”这个约束其实不是单纯为了晒技术它有几个很实际的意义首屏加载速度直接关系到用户留存。移动端弱网环境下1MB 以内的首屏资源体验最好5MB 是一个“还能接受且完整展示 3D 效果”的缓冲上限。体积越大模型解码、纹理上传 GPU 的时间也越长。控制体积有助于减少 WebGL 内存占用避免中低端手机直接崩溃或掉帧。对部署也有好处。无论是放在 GitHub Pages、Vercel 还是自己的服务器静态资源体积小都意味着更快的分发和更低的流量成本。另外一个容易忽略的点是体积控制会反向推动资产质量。你没法把所有作品都塞进首页时就必须做取舍挑选最能代表自己的 3 到 5 个场景或模型。这种“克制”恰好是个人作品集最需要的。1.3 Three.js、WebGL 与 GLB 的关系先用一句话理清概念浏览器本身不支持直接解析 3D 模型它提供的是 WebGL 绘图 API让你能在 canvas 上执行 GPU 渲染Three.js 是对 WebGL 的封装帮你管理场景图、相机、材质、阴影等。GLTF/GLB 是 3D 资源的标准格式类似网页里的 JPEG/PNG浏览器不能直接显示它需要加载后用 Three.js 还原成场景对象。GLB 是 GLTF 的二进制封装把模型网格、材质、纹理都塞进一个文件里便于传输和加载。在做 3D 作品集时我建议统一使用 GLB 作为交付格式而不是 FBX 或 OBJ因为 GLB 对 Web 端支持最好也能与 Draco、meshopt 等压缩方案无缝配合。2. 环境准备与项目初始化2.1 技术栈与版本说明本文示例采用 Vite Three.js。Vite 负责开发服务器和构建打包Three.js 负责 3D 渲染。版本需要根据你的项目实际情况调整下面以常见环境为例重点演示配置思路。npm create vitelatest 3d-portfolio -- --template vanilla cd 3d-portfolio npm install three npm install -D rollup-plugin-visualizerpackage.json 示例{ name: 3d-portfolio, private: true, version: 1.0.0, type: module, scripts: { dev: vite, build: vite build, preview: vite preview }, dependencies: { three: ^0.160.0 }, devDependencies: { vite: ^5.2.0, rollup-plugin-visualizer: ^5.12.0 } }2.2 项目目录结构一个适合 3D 作品集项目的目录结构如下3d-portfolio/ ├── index.html ├── package.json ├── vite.config.js ├── public/ │ ├── models/ │ │ ├── hero.glb │ │ └── works/ │ └── textures/ │ ├── ground.webp │ └── env.hdr └── src/ ├── main.js ├── style.css ├── core/ │ ├── renderer.js │ ├── camera.js │ └── lights.js ├── scenes/ │ ├── heroScene.js │ └── projectScene.js └── utils/ ├── loader.js └── resize.jspublic 目录下的内容会原样拷贝到构建产物中适合放模型和纹理这些静态资产。src 下按“核心模块”和“场景模块”区分方便后面做代码分包。3. Three.js 场景搭建核心代码3.1 创建场景、相机和渲染器先来看一个最基础的入口文件。这个文件负责初始化整个 3D 世界的“骨架”。// 文件路径src/main.js import * as THREE from three; import { OrbitControls } from three/examples/jsm/controls/OrbitControls.js; import { loadGLTF } from ./utils/loader.js; import ./style.css; const container document.getElementById(app); // 1. 场景 const scene new THREE.Scene(); scene.background new THREE.Color(0x0d1117); // 2. 相机 const camera new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.1, 100 ); camera.position.set(5, 3, 8); // 3. 渲染器 const renderer new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.shadowMap.enabled true; container.appendChild(renderer.domElement); // 4. 控制器 const controls new OrbitControls(camera, renderer.domElement); controls.enableDamping true; controls.dampingFactor 0.05; controls.maxPolarAngle Math.PI / 2.2; controls.target.set(0, 0.8, 0);这里有几个关键点需要说明PerspectiveCamera的四个参数分别是视野角度、宽高比、近裁剪面、远裁剪面。近裁剪面和远裁剪面决定渲染的深度范围太小会导致模型被裁掉太大则会影响深度精度。setPixelRatio(Math.min(window.devicePixelRatio, 2))是移动端优化的第一步。很多手机设备像素比是 3如果直接按 3 渲染GPU 负担会翻几倍但视觉提升并不明显。shadowMap.enabled开启阴影后会显著增加渲染开销如果目标是 5MB 以内的轻量站点阴影尺寸不要设置得太大。3.2 灯光配置灯光决定了模型的质感。一个常见的配置是“环境光 主方向光”环境光保证暗面不至于全黑方向光提供主光源和阴影。// 文件路径src/core/lights.js import * as THREE from three; export function setupLights(scene) { const ambientLight new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); const directionalLight new THREE.DirectionalLight(0xffffff, 1.8); directionalLight.position.set(5, 8, 4); directionalLight.castShadow true; directionalLight.shadow.mapSize.set(1024, 1024); scene.add(directionalLight); return { ambientLight, directionalLight }; }shadow.mapSize是阴影贴图的分辨率。作品集网站中 1024 已经足够除非场景里需要非常精细的投影否则没必要用 2048 或 4096。3.3 模型加载封装模型加载是 3D 作品集的灵魂。为了支持 Draco 压缩模型需要同时引入 GLTFLoader 和 DRACOLoader。// 文件路径src/utils/loader.js import { GLTFLoader } from three/examples/jsm/loaders/GLTFLoader.js; import { DRACOLoader } from three/examples/jsm/loaders/DRACOLoader.js; let gltfLoader; function getLoader() { if (gltfLoader) return gltfLoader; const loader new GLTFLoader(); const dracoLoader new DRACOLoader(); // 注意decoder 版本需要与模型压缩时使用的 draco 版本尽量一致 dracoLoader.setDecoderPath(https://www.gstatic.com/draco/versioned/decoders/1.5.7/); loader.setDRACOLoader(dracoLoader); gltfLoader loader; return loader; } export function loadGLTF(url, onReady) { const loader getLoader(); loader.load( url, (gltf) onReady(gltf, null), (xhr) { if (xhr.total 0) { const percent Math.round((xhr.loaded / xhr.total) * 100); updateLoadingBar(percent); } }, (error) onReady(null, error) ); } function updateLoadingBar(percent) { const bar document.getElementById(loading-bar); if (bar) { bar.style.width ${percent}%; } if (percent 100) { const layer document.getElementById(loading-layer); if (layer) { layer.style.opacity 0; setTimeout(() layer.remove(), 500); } } }把加载器封装成单例可以避免每个场景重复创建 GLTFLoader 和 DRACOLoader还能统一处理加载进度和错误回调。解码 Draco 模型需要独立的 decoder 脚本这个脚本放在 gstatic CDN 上也可以下载到本地 public 目录自行托管避免外部网络依赖。在 main.js 中调用模型加载// 继续在 src/main.js 中添加 import { setupLights } from ./core/lights.js; setupLights(scene); loadGLTF(/models/hero.glb, (gltf, error) { if (error) { console.error(模型加载失败, error); return; } const model gltf.scene; model.traverse((child) { if (child.isMesh) { child.castShadow true; child.receiveShadow true; } }); scene.add(model); });model.traverse用于遍历模型内部的所有子节点把阴影开关统一设置好。很多导出的模型默认不开阴影如果你发现模型没有投影大概率是这里没有处理。3.4 动画循环与窗口自适应最后是动画循环和窗口自适应这两段几乎是每个 Three.js 页面都要写的通用逻辑。// 文件路径src/main.js续 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); });controls.update()在开启 damping 后必须放在动画循环里否则旋转惯性效果不会生效。resize时除了更新相机宽高比还要调用renderer.setSize否则 canvas 会变形或模糊。4. 5MB 体积控制的核心策略4.1 模型导出与 Draco 压缩体积控制的第一战场是模型资产。一个未经压缩的 GLB 很容易达到 10 到 30MB而经过 Draco 压缩后通常能减少 60% 到 80%。Draco 是 Google 开源的几何压缩算法通过重新编码顶点位置、法线和纹理坐标来减小文件体积Three.js 提供了现成的 DRACOLoader 支持。在 Blender 中导出 GLB 时勾选“Draco compression”即可。如果手里已经有现成的 GLB也可以用命令行工具二次压缩npx gltf-transform/cli optimize hero_raw.glb hero.glb \ --compress draco \ --texture-compress webp \ --texture-size 1024另一个常用工具是 gltfpack它的特点是压缩强度高、速度快gltfpack -i hero_raw.glb -o hero.glb -cc -tc其中-cc开启网格压缩-tc开启纹理压缩。具体参数以你安装的工具版本为准不同版本对纹理格式的支持会有差异。需要特别提醒Draco 压缩后的模型必须配合 DRACOLoader 解码否则会报“Draco decoder not available”之类的错误。DRACOLoader 的 decoder 本身也有约几百 KB 的体积如果站点部署在国内服务器建议把 decoder 文件下载到本地避免加载 gstatic 地址时出现网络问题。4.2 纹理压缩与尺寸控制纹理是另一个体积黑洞。一张 2048 的 PNG 贴图可能就有 5 到 8MB压缩后变成 WebP 或 KTX2 会小很多。实际项目中我建议按这个优先级处理凡是程序化生成的颜色优先用材质颜色而不是贴图。必须用贴图时漫反射贴图控制在 1024 以内粗糙度、金属度这类辅助贴图 512 就足够。输出格式优先选择 WebP 或 KTX2。WebP 在三端兼容性较好KTX2 则能直接由 GPU 解码加载更快。环境贴图不要用单一的 HDR 大文件可以用 Three.js 的PMREMGenerator结合低分辨率 HDR 生成或者直接使用 2x2 的压缩环境图。在 Three.js 中使用 KTX2 纹理时需要额外引入 KTX2Loaderimport { KTX2Loader } from three/examples/jsm/loaders/KTX2Loader.js; import { GLTFLoader } from three/examples/jsm/loaders/GLTFLoader.js; const ktx2Loader new KTX2Loader(); ktx2Loader.setTranscoderPath(/basis/); ktx2Loader.detectSupport(renderer); const loader new GLTFLoader(); loader.setKTX2Loader(ktx2Loader);setTranscoderPath指向的是 Basis Universal 的转码脚本目录需要把 three.js 包里的examples/jsm/libs/basis/下的文件复制到 public 目录。4.3 代码分包与动态加载资产压缩解决模型和贴图体积代码层面则要解决“首屏只加载必要代码”的问题。Three.js 框架本身压缩后大约 150 到 200KB这部分可以单独打成 chunk利用浏览器缓存。各个场景的初始化代码则通过动态 import 按需加载。Vite 配置示例// 文件路径vite.config.js import { defineConfig } from vite; import { visualizer } from rollup-plugin-visualizer; export default defineConfig({ build: { chunkSizeWarningLimit: 1000, rollupOptions: { output: { manualChunks: { three: [three], }, }, }, }, plugins: [visualizer({ open: true, gzipSize: true })], });manualChunks把 three 单独拆成一个 chunk这样页面切换时浏览器不会重复下载 Three.js 核心代码。visualizer插件会在 build 后生成一个可视化体积报告方便定位哪些模块最占空间。动态加载场景模块// src/main.js 中按需引入首屏场景 async function initHeroScene() { const { default: createHeroScene } await import(./scenes/heroScene.js); createHeroScene(scene, camera, renderer); } initHeroScene();这里的原理是heroScene.js 只有在用户进入页面后才加载而不是在入口文件里一次性打包。这样首屏 JavaScript 体积可以控制在很小范围模型、纹理、额外场景都按需加载。4.4 总包体预算表5MB 不是一个魔法数字而是一个需要拆解的预算。我在做自己的站点时大致按下面的预算规划资源类型预算说明3D 模型2 到 3MB全部模型压缩后的总和纹理贴图1MB 左右统一转为 WebP/KTX2JavaScript0.5 到 1MB压缩后体积含 Three.js字体与样式0.5MB 以内使用可变字体或系统字体其他资源0.5MB图片、音效等占位有了预算表开发时就能快速判断“能不能加这个模型”“这张贴图要不要降分辨率”。建议把预算表写进 README每次构建后通过脚本统计 build 产物大小超过预算就报警。5. 性能优化与加载体验5.1 渲染性能优化体积小不代表渲染就一定流畅渲染性能主要取决于 draw call 数量和 GPU 负担。对于作品集网站常见的优化手段有合并几何体多个静态网格如果使用相同材质可以用BufferGeometryUtils.mergeGeometries合并减少 draw call。使用 InstancedMesh场景中有大量重复物体比如树木、粒子、观众席座位用 InstancedMesh 一次绘制。控制阴影能不开阴影的物体尽量不开动态物体优先手动烘焙 AO 贴图。降低像素比移动端Math.min(window.devicePixelRatio, 2)已经是业内默认做法中低端机甚至可以限制到 1.5。使用 LOD远景模型加载低精度版本镜头靠近后再切换高精度版本适合场景较大的项目。在调试渲染性能时可以打开renderer.info查看当前帧的 draw call、三角形数量等指标。如果 draw call 超过 200就需要考虑合并或实例化。5.2 加载进度与降级方案3D 页面最怕两种情况一是加载慢用户盯着空白页二是设备不支持 WebGL直接白屏。这两种情况都要提前处理。加载进度条可以通过刚才封装好的updateLoadingBar实现。更完善的做法是展示一个简单的“加载中”动画并提示用户等待。模型加载完成后淡出遮罩层给用户一个平滑的过渡体验。WebGL 支持检测是降级方案的基础function isWebGLAvailable() { try { const canvas document.createElement(canvas); return !!( window.WebGLRenderingContext (canvas.getContext(webgl) || canvas.getContext(experimental-webgl)) ); } catch (e) { return false; } } if (!isWebGLAvailable()) { document.getElementById(app).innerHTML p当前浏览器不支持 WebGL已切换为 2D 版本/p; init2DFallback(); }当用户浏览器不支持 WebGL 时可以降级为普通图文页面确保个人作品集依然可访问。这个降级页面在 SEO 和可访问性上也有额外价值。5.3 移动端适配3D 作品集有很大一部分流量来自手机移动端适配不能只在 CSS 里做响应式还要处理 3D 交互本身的差异。以下几点是我实际开发中踩完坑后整理的建议给 canvas 设置touch-action: none避免用户旋转模型时页面跟着滚动。移动端默认关闭阻尼效果或者调大阻尼系数否则滑动体验会很粘。阴影贴图尺寸在移动端降到 512方向光数量减少一个。监听resize和orientationchange横竖屏切换后要重新计算相机宽高比。如果是全屏 3D 页面建议在手机上默认显示一个“进入 3D 场景”的按钮用户点击后再初始化 WebGL避免一进来就卡顿。6. 常见问题与排查思路做 3D 作品集网站时下面这些问题出现频率最高整理成表格方便大家快速定位。问题现象常见原因解决思路模型加载后表面是黑色场景没有灯光或材质贴图未正确加载添加环境光和方向光检查贴图路径与格式模型报错无法加载Draco 版本不匹配或缺失 decoder确认 DRACOLoader 的 decoder 路径和压缩时版本对齐打包后体积仍然很大模型未压缩或纹理为 PNG使用 Draco 压缩模型纹理转 WebP/KTX2移动端帧率低像素比过高、阴影开销大限制 devicePixelRatio降低阴影贴图尺寸首屏白屏很久所有资源都在入口文件同步加载使用动态 import 和按需加载模型出现闪烁或穿模z-fighting 或相机裁剪距离过小调整 near/far 值避免面片重叠页面切换后内存持续增长旧场景的几何体、纹理未释放切换场景时遍历并 dispose 所有资源这里重点说一下内存释放。单页 3D 应用在页面切换时如果不手动释放几何体和纹理GPU 内存会持续累积最终导致移动端浏览器崩溃。释放代码通常放在场景销毁函数里function disposeScene(scene) { scene.traverse((child) { if (child.isMesh) { child.geometry.dispose(); if (child.material) { const materials Array.isArray(child.material) ? child.material : [child.material]; materials.forEach((material) { for (const key in material) { const value material[key]; if (value value.isTexture) { value.dispose(); } } material.dispose(); }); } } }); scene.clear(); }这个函数在切换到其他场景前调用可以显著减少内存泄漏问题。7. 最佳实践与工程建议7.1 建立资源处理流水线3D 作品集不是一次性开发后期会不断加入新作品。建议把“原始模型 → 压缩 → 输出到 public/models”这个过程脚本化而不是每次手动用 Blender 导出。可以在 package.json 中增加脚本{ scripts: { assets:optimize: node scripts/optimize-assets.mjs } }脚本内部调用 gltfpack 或 gltf-transform/cli遍历原始资产目录把 GLB、纹理统一压缩后输出。这样新增作品时只跑一次命令就能保证所有资产都符合体积预算。7.2 统一场景模块接口每个场景模块都建议导出同一个结构的函数方便动态 import 时统一管理// 文件路径src/scenes/heroScene.js export default function createHeroScene(scene, camera, renderer) { // 创建该场景专属的对象、灯光、动画 // 返回销毁函数用于切换场景时释放资源 return () disposeScene(localObjects); }入口文件只需要拿到返回值在切换时调用即可。这个设计虽然简单却能避免多个场景之间状态互相污染也方便后续添加新的作品场景。7.3 安全与合规提醒如果你的网站会加载外部模型资源要注意模型来源的可靠性。不要随意加载不可信域名的 GLB 文件尽量把模型资源放在自己控制的 CDN 或服务器上。Three.js 对于纹理图片的跨域加载有 CORS 限制需要确保资源服务器正确配置了Access-Control-Allow-Origin响应头。同时任何涉及用户数据上传、模型分享的功能都要在测试环境中充分验证遵循最小权限原则不要开放不必要的上传接口。7.4 持续监控体积与性能体积控制是一个持续过程。建议在 CI 中加入构建产物体积检查例如用rollup-plugin-visualizer生成报告或者用简单的脚本读取dist/assets目录下所有文件大小并求和。超过 5MB 预算就中断构建提醒开发者处理资产。性能方面可以在开发环境挂一个 FPS 面板观察不同页面在手机模拟模式下的帧率。上线后配合 Lighthouse 或 Performance 面板记录 FCP、LCP 指标不断优化加载顺序和资源优先级。可以看到5MB 约束真正倒逼出来的是一个完整的工程体系资产压缩、代码分包、加载降级、性能监控。那位 HN 作者说这个项目迭代了 2.5 年这个周期的意义不在于写代码本身而在于反复打磨场景、裁切资源、修兼容性——这些恰恰是 3D 个人站和个人站点开发中最容易被低估的工作量。如果你也想做一个类似的 3D 作品集建议从最简单的一个模型、一个场景、一张贴图开始先把“动态加载 体积预算”这套骨架跑通再逐步丰富作品内容。不要把两年半当成心理门槛那个数字背后最值得学习的只是“把一件小事用足够长的时间打磨到极致”而已。