资讯动态

Three.js实战:构建可复用的Web 3D场景开发脚手架

发布时间:2026/10/27 7:25:50 来源:尧图企业网站定制
1. 项目缘起“t3code”到底是个什么项目先说个题外话。有朋友看到“t3code”这个名字第一反应是“跟 t3three.js有关系还是某个工具链的版本代号”其实我最初起这个名字的时候想法很简单——t3 就是“Three.js”的缩写变体code 是核心合起来就是“用 Three.js 写代码做 3D 场景”的一个个人实战项目。后来做着做着它慢慢变成了一个完整的 Web 3D 开发脚手架包含场景搭建、模型加载、动画控制、性能优化等一系列模块。这个项目解决的痛点很实际很多刚接触 Web 3D 的开发者照着官方文档能跑起来一个旋转立方体但一旦遇到模型加载失败、纹理失真、动画卡顿、内存泄漏这些问题就完全不知道怎么排查。t3code 就是把我在实际项目里踩过的坑、积累的经验固化成一套可以直接用的代码结构和工具方法。也就是说它不是一个玩具 demo而是一个偏工程向的 Three.js 开发基础框架。如果你正好在做 Web 3D 可视化、个人作品集、产品展示页或者想入门 Three.js 但不知道从何下手这个项目很值得看看。哪怕你只有 JavaScript 的基础跟着文章里的思路走一遍也能对自己的 3D 项目有一个全局的掌控感。2. 整体架构为什么我把代码拆成模块而不是一个文件写到底很多新手写 Three.js习惯把所有东西都塞进一个index.js里场景、相机、渲染器、模型、动画全在一起。几百行还好一旦项目到几千行改一个光照参数要找半天加了新功能又怕影响旧逻辑。t3code 的第一版就是这么干的后来重构的时候我做了彻底拆分。2.1 核心模块划分我的目录结构大致是这样src/ ├── core/ │ ├── scene.js # 场景创建与全局管理 │ ├── camera.js # 相机配置 │ ├── renderer.js # 渲染器初始化 │ └── loop.js # 渲染循环与动画帧管理 ├── objects/ │ ├── lights.js # 灯光配置 │ ├── models.js # 模型加载与处理 │ └── environment.js # 地面、天空、环境辅助 ├── interactions/ │ ├── controls.js # OrbitControls 封装 │ ├── raycast.js # 鼠标拾取与交互 │ └── animations.js # 动画状态机 └── utils/ ├── loader.js # 加载进度管理 └── debug.js # 调试辅助信息这么拆的好处非常明显职责单一每个文件只负责一件事改灯光的不会碰到相机调动画的不影响渲染。便于维护出了问题看文件名就能定位到大概位置不用从头翻代码。可复用新的页面或者新的项目直接复制core和utils文件夹改改配置就能用。提示模块拆分不是越细越好。如果一个小文件只有几行代码那拆分的意义就不大了。我的原则是一个文件至少能独立承担一个明确的功能并且有被其他模块引用的可能才值得拆出来。2.2 初始化时序哪些代码必须在前哪些可以在后Three.js 项目的初始化时序很多人没太在意但实际运行效果差很多。我的顺序是创建场景scene——这是容器其他东西都要放进来。初始化相机camera——相机的参数决定你从哪个角度、以什么视野看场景。创建渲染器renderer——渲染器需要拿到 HTML 容器并设置好尺寸。配置灯光——灯光会影响后续加载模型的显示效果最好在模型加载之前就位。加载模型/创建几何体——模型加载是异步的早启动能早完成。启动渲染循环——所有元素齐了之后再开始循环避免空转。绑定交互事件——鼠标、键盘、缩放等事件最后挂载。这个顺序背后有个逻辑** 尽量让耗时的异步操作模型加载提前开始同时保证同步的初始化都完成后再启动动画循环**。如果你一开始就启动渲染循环但模型还没加载进来页面就会先显示一个空场景体验非常差。3. 核心代码解析从零搭建一个可复用的 Three.js 基础场景这一节咱们直接看代码。我会展示 t3code 里最核心的几个模块然后解释每个关键参数的含义和为什么这样设置。3.1 场景模块不止是new THREE.Scene()那么简单基础场景创建看起来很简单import * as THREE from three; export function createScene() { const scene new THREE.Scene(); // 背景色二选一 scene.background new THREE.Color(0x0a0a1a); // 纯色背景 // scene.background new THREE.CubeTextureLoader().load([...]); // 天空盒 // 雾效增加真实感同时优化远处物体的渲染 scene.fog new THREE.Fog(0x0a0a1a, 20, 60); return scene; }很多人忽略了fog雾效这个属性。它的作用有两个层面视觉层面场景远处的物体逐渐融入背景色产生空间深度感。做室外场景或者街道效果的时候有没有雾差别非常大。性能层面雾效配合相机的far裁剪面可以减少远处物体的可见细节直接降低 GPU 的渲染压力。不过要注意Fog的两个参数near和far是距离值依据你的场景尺度来设。我的场景是 20 到 60如果你的模型很大这个数值要相应放大否则模型刚出现就会被雾盖住。3.2 渲染器配置抗锯齿与像素比是首要注意项渲染器是 Three.js 的“输出窗口”它的配置决定了画面质量的上限。我最常用的配置export function createRenderer(containerId) { const container document.getElementById(containerId); const renderer new THREE.WebGLRenderer({ antialias: true, // 开启抗锯齿 alpha: true, // 允许透明背景 preserveDrawingBuffer: true, // 截图/保存时用 }); renderer.setSize(container.clientWidth, container.clientHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.shadowMap.enabled true; renderer.shadowMap.type THREE.PCFSoftShadowMap; container.appendChild(renderer.domElement); return renderer; }这里有两个参数我特别想强调。第一setPixelRatio(Math.min(window.devicePixelRatio, 2))。如果你的屏是 Retina 屏devicePixelRatio是 2 或者 3如果你直接按 100% 渲染画面会发糊如果你直接用原始的设备像素比比如 3性能开销会爆炸。限制最大 2 是性能和画质的平衡点实际项目里 90% 的场景看不出 2 和 3 的区别但性能差很多。第二shadowMap.type THREE.PCFSoftShadowMap。PCFSoftShadowMap是柔和阴影视觉效果更自然代价是性能略低。如果是移动端或者低端设备我通常会把它改成THREE.PCFShadowMap牺牲一点点画质换流畅度。这个怎么取舍后面性能优化部分我会细说。3.3 渲染循环用requestAnimationFrame还是renderer.setAnimationLoopThree.js 从 r123 版本开始官方推荐用renderer.setAnimationLoop()。这两者的区别在于requestAnimationFrame是浏览器通用的帧回调方法需要自己管理循环。renderer.setAnimationLoop()是渲染器内置的方法在 WebXR 场景下会自动切换到设备的刷新频率兼容性更好。我建议直接用setAnimationLoop代码更简洁export function startLoop(renderer, scene, camera, onUpdate) { const clock new THREE.Clock(); renderer.setAnimationLoop(() { const delta clock.getDelta(); // 调用外部传入的自定义逻辑如控制模型运动 if (onUpdate) onUpdate(delta); // 渲染当前帧 renderer.render(scene, camera); }); }这里的THREE.Clock是官方提供的时钟工具getDelta()返回的是上一帧到这一帧的时间间隔单位是秒。所有跟时间相关的动画位移、旋转、缩放都应该基于delta做增量计算而不是写死在每帧固定加一个值。因为不同设备的帧率不同60Hz 和 144Hz 的屏如果都用每帧加 0.1跑出来的速度会差 2 倍多。用delta做增量才能保证在不同刷新率下动画速度一致。3.4 相机控制OrbitControls 的参数调教心得OrbitControls轨道控制器是所有 3D 展示项目的标配用鼠标拖拽旋转、滚轮缩放、右键平移。我在 t3code 里封装了一份import { OrbitControls } from three/examples/jsm/controls/OrbitControls.js; export function createControls(camera, renderer.domElement) { const controls new OrbitControls(camera, renderer.domElement); controls.enableDamping true; // 惯性效果 controls.dampingFactor 0.08; // 惯性系数 controls.enablePan true; // 允许平移 controls.minDistance 5; controls.maxDistance 50; controls.maxPolarAngle Math.PI / 2; // 限制不能到地下 controls.target.set(0, 2, 0); return controls; }几个参数说明enableDamping开启后鼠标松开会有一段平滑的惯性滑动比生硬地“指哪打哪”舒服很多。但记得要在渲染循环里调用controls.update()否则阻尼不生效。maxPolarAngle设为Math.PI / 2表示相机的垂直角度最多到水平线这样用户不会把视角翻到场景底下对大多数地面场景都适用。minDistance和maxDistance控制缩放范围防止用户穿过物体或者缩得太远看光整个场景。3.5 模型加载进度、错误处理、坐标归一化实际项目里模型基本都是外部导入以 glTF 格式为主.glb/.gltf。glTF 是当前 Web 3D 的通用标准格式Three.js 原生支持。加载模型最核心的问题有三个进度反馈、异常处理、模型尺寸。import { GLTFLoader } from three/examples/jsm/loaders/GLTFLoader.js; export function loadModel(url) { return new Promise((resolve, reject) { const loader new GLTFLoader(); loader.load( url, (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()); const maxDim Math.max(size.x, size.y, size.z); const scale 10 / maxDim; model.position.x - center.x; model.position.y - center.y; model.position.z - center.z; model.scale.setScalar(scale); resolve(model); }, (progress) { // 加载进度可用于 UI 展示 const percent (progress.loaded / progress.total) * 100; console.log(Loading: ${percent.toFixed(1)}%); }, (error) { reject(error); } ); }); }这段代码里坐标归一化是我最想推荐给所有人的实践。从网上下载的模型建模软件里的单位、原点位置、物体尺寸千奇百怪有的模型在原点有的在很远的地方有的巨大有的微小。如果不做处理直接扔进场景就会出现“模型找不到”“场景只有一个小点点”这种经典问题。上面这段逻辑是用Box3.setFromObject计算模型的包围盒。拿包围盒的中心和尺寸。先平移模型让中心回到原点。再统一缩放让最长边的长度变成 10 个单位。这样不管原始模型什么样放进场景里都会居中、大小可控后续调整光照和相机就能直接工作。注意progress回调里progress.total不一定总是 0特别是本地文件或某些服务器环境下total可能为 0这时百分比会变成Infinity。要做个判断progress.total 0时才计算百分比。4. 性能与体验优化帧率、内存、加载时间的实战调优Web 3D 项目最容易翻车的不是“跑不起来”而是“跑起来了但很卡”。很多开发者觉得“Three.js 卡是正常的东西多嘛”其实大多数时候不是东西多而是代码写得不合理。我从 t3code 的实践中总结了三个层面的优化。4.1 渲染层面几何体合并与 draw call 数量WebGL 的渲染瓶颈很大程度在draw call绘制调用的数量上。CPU 每调用一次drawElements就向 GPU 发一次指令。如果你场景里有 100 个旋转的小方块每个方块是一个单独的Mesh那每帧就有 100 次 draw call。如果它们还是同一个几何体、同一种材质完全可以把它们合并成一个几何体。合并的思路有两种静态合并使用BufferGeometryUtils.mergeGeometries把多个几何体合并成一个只做一次 draw call。适合静止不动的物体比如地面、墙、树木的静态部分。实例化使用InstancedMesh适合大量相同几何体的场景比如森林里的同种树木、城市里的同款路灯。InstancedMesh允许你通过矩阵数组控制每个实例的位置和旋转但本质上只做一次绘制调用。我做过一个灯光矩阵的实验用普通 Mesh 放了 100 个球体帧率大概在 45 fps换成 InstancedMesh 同样的效果直接拉满 60 fps。数量越多差距越明显。不过要注意合并几何体之后单个物体就没法独立控制了比如单独旋转某一个。所以这是一个取舍动态物体不要合并静态物体尽量合并。4.2 纹理处理尺寸、压缩格式与色彩空间Web 3D 项目里最影响加载速度的往往不是模型文件而是贴图纹理。一个 4K 分辨率的 JPG 动辄好几 MB手机网络下加载体验非常糟糕。我常用的做法能压缩就压缩分辨率用 2048 或 1024 往往足够场景里的背景图才需要 4K。选择轻量格式现代浏览器建议用 WebP 或者 JPEG除非需要透明通道才用 PNG。开启纹理压缩如果走正式工程化部署可以考虑把纹理转成KTX2或Basis Universal格式这个需要额外的转换工具链但对性能提升很大。还有一个很多人容易忽视的色彩空间。Three.js r152 之后默认做了色彩管理renderer.outputColorSpace默认是SRGBColorSpace。如果你加载的贴图是直接在别的项目里抠过来的颜色明显发灰或者发暗十有八九是色彩空间设置对不上。我的经验是如果你用的是官方 GLTFLoader 加载模型基础颜色和贴图一般不需要手动干预但如果你在 Photoshop 里保存的贴图不带 sRGB 标记就得在texture.colorSpace THREE.SRGBColorSpace上手动设置。4.3 内存管理释放不再需要的资源Three.js 的 JavaScript 对象有垃圾回收机制但 GPU 上的资源纹理、几何体、材质不会自动释放。这是内存泄漏的高发地。我在 t3code 里写了一个disposeObject方法export function disposeObject(obj) { if (!obj) return; if (obj.geometry) { obj.geometry.dispose(); } if (obj.material) { if (Array.isArray(obj.material)) { obj.material.forEach((mat) disposeMaterial(mat)); } else { disposeMaterial(obj.material); } } // 递归处理子节点 obj.children?.forEach((child) disposeObject(child)); } function disposeMaterial(mat) { // 释放材质相关的纹理 for (const key in mat) { if (mat[key] mat[key].isTexture) { mat[key].dispose(); } } mat.dispose(); }这个函数在切换场景、关闭页面、替换模型的时候调用可以避免 GPU 显存被占满导致新内容加载变慢甚至崩溃。尤其在做 SPA单页应用的时候路由切换后旧场景不释放就会出现开几个页面就卡死的问题。4.4 加载体验从“白屏等待”到“进度提示”模型加载需要时间这个时间不管你是本地还是服务器都会存在。与其让用户看着空白页面发呆不如做一个简单的加载进度提示。我用的是LoadingManagerimport { LoadingManager } from three; export function setupLoadingManager(onProgress, onComplete) { const manager new LoadingManager(); manager.onProgress (url, loaded, total) { const percent Math.round((loaded / total) * 100); onProgress?.(percent); }; manager.onLoad () { onComplete?.(); }; manager.onError (url) { console.error(Failed to load: ${url}); }; return manager; }用LoadingManager的好处是它能统一管理所有通过 Three.js 加载器发起的请求——GLTFLoader、TextureLoader、AudioLoader 都会经过它。你只需要维护一个进度条 UI所有资源加载都会反映到同一个百分比上不用为每个加载器单独写回调。实操心得进度条不要真的从 0 按到 100%那样太机械。我一般是 0 开始快速到 20%然后根据加载进度缓慢推进到最后 90% 左右停住等场景渲染第一帧时瞬间跳到 100%。这样用户的心理感受比“真实加载”更平滑。这个技巧在游戏行业叫“假装加载”但原理和用户体验是相通的。5. 交互与动画让场景“活”起来而不是“定”住静态的 3D 场景看起来没什么问题但它缺少“生命力”。我在 t3code 里加了两层交互一层是鼠标拾取点击/悬停物体触发反应一层是时间轴动画物体循环运动。5.1 射线拾取点不中模型的三种原因射线拾取Raycasting的原理是从相机位置出发通过鼠标坐标的归一化方向发射一条射线检测它穿过的所有物体。代码实现并不复杂export function setupRaycast(renderer, camera, objects, onPick) { const raycaster new THREE.Raycaster(); const pointer new THREE.Vector2(); renderer.domElement.addEventListener(click, (event) { // 将鼠标位置转换为归一化设备坐标-1 到 1 pointer.x (event.clientX / window.innerWidth) * 2 - 1; pointer.y -(event.clientY / window.innerHeight) * 2 1; raycaster.setFromCamera(pointer, camera); const intersects raycaster.intersectObjects(objects, true); if (intersects.length 0) { const target intersects[0].object; onPick?.(target); } }); }这里容易踩的三个坑坐标系转换错鼠标坐标要先除以窗口尺寸再映射到 [-1, 1] 区间。很多人忘了除以宽高直接用像素坐标导致射线方向完全偏了。objects传参不对intersectObjects的第二个参数recursive必须设为true否则它只会检测你传入的物体本身而不会检测它的子节点。尤其对于 glTF 模型来说真正的网格在模型内部的多层子节点里不开启 recursive 基本点击不到。模型未更新包围盒有些模型加载后有动画骨骼更新后包围盒保持初始状态会导致射线提前命中或漏检。这种情况下要考虑用mesh.geometry.boundingBox的动态更新逻辑或者把射线检测对象换成实际的碰撞体。5.2 基于时间的动画旋转、缩放、弹跳我常用的动画模式有两种一种纯数学计算一种基于曲线路径。旋转与缩放非常简单function updateAnimation(delta) { group.rotation.y delta * 0.5; // 每秒转 0.5 弧度 const pulse Math.sin(elapsedTime * 2) * 0.2 1; // 0.8 ~ 1.2 之间波动 group.scale.setScalar(pulse); }更复杂的路径动画可以用CatmullRomCurve3曲线import { CatmullRomCurve3, Vector3 } from three; const curve new CatmullRomCurve3([ new Vector3(-5, 1, 0), new Vector3(0, 2, 3), new Vector3(5, 1, 0), new Vector3(0, 0, -3), ]); let t 0; function updatePathAnimation(delta) { t (t delta * 0.3) % 1; // 0~1 循环 const position curve.getPointAt(t); // 均匀速度取点 object.position.copy(position); }这里有一个小细节要注意getPointAt返回的是曲线上的点是按弧长均匀取点的而getPoint是按参数均匀取点的。对于控制点不均匀的曲线getPoint会导致速度忽快忽慢一定要用getPointAt。5.3 与 DOM 的桥接点击模型弹出信息卡片Web 3D 不是孤立的它在一个网页里。我经常需要“点击 3D 模型上的某个零件 → 在旁边弹出一个 HTML 卡片展示说明”这种交互。实现的思路是用射线拾取到目标物体后计算它的世界坐标然后转换成屏幕坐标再把 HTML 元素定位到那个位置。function getScreenPosition(object3D, camera, container) { const vector new THREE.Vector3(); object3D.getWorldPosition(vector); vector.project(camera); // 转归一化设备坐标 // 换算到屏幕像素坐标 const x (vector.x * 0.5 0.5) * container.clientWidth; const y (-vector.y * 0.5 0.5) * container.clientHeight; return { x, y }; }在setAnimationLoop里持续更新这个位置卡片就会始终跟着模型走。这里要额外处理一个情况当物体旋转到相机背面时vector.z可能是一个接近 1 的值或大于 1这时就说明物体在视野之外应该主动把卡片隐藏。否则卡片会出现在屏幕的某个诡异角落。6. 坑点速查表我踩过的那些“文档没写清楚”的坑下面这些是我在项目过程中反复遇到的问题整理成速查表给后来者避坑。现象可能原因解决方案模型是黑色/不发光的材质没有响应光照或没有添加光源检查使用的是什么材质MeshLambertMaterial/MeshStandardMaterial 需要光源MeshBasicMaterial 永远不受光照影响模型加载后看不到模型尺寸过大或过小或原点偏移用包围盒做坐标归一中量化把最长边缩放到 10 个单位左右阴影全是硬边像锯齿默认 PCFShadowMap 精度不足增加directionalLight.shadow.mapSize.set(1024, 1024)或更高或者改用 PCFSoft帧率为什么突然掉一半阴影贴图过大或像素比过高将shadow.mapSize降到 512将setPixelRatio限制为 1 或 2颜色和 Photoshop 里看到的不一样色彩空间不一致把贴图的texture.colorSpace手动设成THREE.SRGBColorSpace透明物体场景里排序错乱透明材质本身就会存在遮挡问题给对应材质设置depthWrite: false并按透明物体之间的距离做渲染排序点击模型没反应Raycaster 没开 recursive或坐标系转换错检查intersectObjects(objects, true)的第二个参数检查鼠标归一化坐标公式页面切换后GPU 占用不降场景销毁时没释放资源调用disposeObject遍历释放 geometry/material/textureWebP 纹理在某些浏览器上是黑图浏览器不支持 WebP 解码或安全限制加一个THREE.DefaultLoadingManager的错误回调或者回退 JPEG模型加载进度永远 0%progress.total 在某些环境下为 0判断total 0时才更新百分比这个表不是万能的但覆盖了 90% 的新手问题。排查的思路永远是先控制变量把模型换成 BoxGeometry 看是否正常把贴图全去掉看颜色是否正常把动画全停掉看帧率是否正常这样一圈下来基本能定位到问题在哪一层。7. 工程化扩展t3code 后续还能怎么“玩”如果你已经照着前面的代码把基础场景搭起来了我建议再往这三个方向走它们会让 t3code 从“能跑”变成“好用”。第一是接入环境贴图Environment Map。不用复杂的光照系统只需加载一张 HDR 环境贴图就能让标准材质产生自然反射和高光场景质感立马上一个档次。可以用RGBELoader加载.hdr文件配合THREE.PMREMGenerator生成预滤波的环境贴图。这个方案唯一的「成本」是加载一个几 MB 的贴图文件但视觉收益远大于性能开销。第二是加入状态管理与 UI 联动。如果你在做一个大型项目推荐用简单的发布订阅模式来管理场景状态比如SceneController负责接收 UI 层的指令然后操作 3D 场景里的物体。这样 UI 代码里不再直接写 Three.js 语句后面重构或换人维护都轻松很多。第三是虚拟场景导览Virtual Tour。在 t3code 的基础上加一个简单的相机动画系统实现“点击某个点位 → 相机平滑飞行过去→ 到达后再让用户自由操作”的效果。这个功能在建筑可视化、家居展示类项目里非常受欢迎。相机飞行的逻辑不复杂用TWEEN库做插值或者自己用lerp函数手写平滑过渡都行核心是把相机的 position 和 controls.target 同时往目标点逼近。我个人在实际操作中体会最深的一点是Three.js 项目最怕的不是「不会用某个 API」而是没有一个清晰的代码组织架构。t3code 这个项目本质上就是在帮你解决这件事——把常用的能力和逻辑固化成模块让每次新项目都从一个比较高的起点开始而不是每次都从头踩一遍坑。如果你照着文章的思路搭了一套自己的场景运行起来后在真机上快速转两圈再打开 DevTools 看下帧率和网络加载情况你大概率会发现自己已经比第一个“旋转立方体”的阶段往前走了很大一步。后面遇到任何具体的问题记住一个原则:把问题拆小、拆到最小可复现单元按模块去调试永远是最快找到答案的方法。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑