简介:这是基于Three.js搭建3D库房场景的完整资源包,内容覆盖3D密集架、3D档案室等典型空间布局,适合刚接触Web 3D开发、想借助实际项目掌握Three.js场景构建与基础交互逻辑的前端或三维可视化学习者。资源一共包含128个文件,压缩包大小约13.13MB。内部以33个js脚本为核心,负责场景初始化、相机控制、模型加载、简单动画与交互逻辑;4个gltf模型配合3个bin数据,呈现库房密集架等设备的几何结构与二进制资源;72个png和8个jpg图片主要作为材质贴图、环境背景和界面素材;另有6个json配置文件、1个html入口示例与1个config文件,可以直接打开运行,也便于按类型定位代码。当前已有2424人学习下载。由于刻意去掉了一些复杂难懂的模块,只保留最容易理解的核心流程,打开html即可预览3D库房效果;对照js脚本、gltf模型和贴图资源,可以完整梳理从创建场景、导入模型、设置材质到加入交互响应的开发链路,是一份适合Three.js入门阶段反复对照的实践资源。
1. 第二版 three.js 搭建 3D 库房:先想清楚这一版要解决什么
第二版 three.js 搭建一个 3D 库房,不是把第一版的模型换得更炫,而是要让“3D密集架”真的能用于日常作业。做档案管理可视化的人应该有同感:演示版摆几个盒子很容易,一旦密集架数量到了几十列、每列有层有格、还要点选定位和开架动画,第一版的代码马上露出马脚——模型用手敲坐标、动效靠临时变量、页面在普通办公电脑上一动就卡。这篇文章按第二次迭代该有的思路,从场景底座、密集架参数化建模、数据绑定写到性能避坑。适合正在用 three.js 做 3D 网页渲染的开发者,也适合要给客户交付 3D 档案室的团队。后面的代码都是我在同类项目里验证过的写法,你照着跑通后再按自己库房数据替换。
2. 场景底座与视角控制:第二版不再从零手写渲染循环
2.1 GLTF 还是几何体组合:第二版场景底座的选型
先回答问题:3D 库房里那些密集架,到底用建模软件导出 GLTF,还是用 BoxGeometry 直接用代码拼?我见过不少第一版项目倒在“导出-导入”这个环节:模型设计师给的是带大量细分的高模,放进 three.js 页面后下载慢、旋转卡,贴图要么不显示要么颜色发灰。密集架这种产品规律性非常强——一排架子就是左右立柱、若干层板、一块背板,用编辑器做还不好改尺寸,参数一变就得重新导出。
第二版我一般会做成混合方案:密集架用代码生成的参数化几何体,档案室的地面、墙面,以及一些装饰部件用 GLTF 导入。参数化架体的好处是,后端返回多少列多少层,前端直接生成对应架子,不用每间库房手工摆模型;如果你不需要精细化模型,连建模软件这步都可以省。整个场景由数据驱动,也正是“第二版”最核心的价值。
这个选型带来一个约束:场景里大部分对象应该是可预测的 Box/Plane 组合,方便统一设置阴影、材质和 userData。后面分场景说。
2.2 最小场景三件套:相机、渲染器与 OrbitControls 参数
第一版常见的问题是相机没有限制,鼠标滚轮能滚到地板下面去,视角转得深一点就穿模。第二版里我会把最小场景脚本固定成这样,作为所有项目的启动模板:
import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; const scene = new THREE.Scene(); scene.background = new THREE.Color(0x1f2233); const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 600); camera.position.set(22, 14, 26); 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.target.set(0, 1, 0); controls.maxPolarAngle = Math.PI / 2.1; // 防止俯视到地板以下 controls.minDistance = 3; controls.maxDistance = 45; controls.enableDamping = true; controls.dampingFactor = 0.08; function renderLoop() { requestAnimationFrame(renderLoop); controls.update(); renderer.render(scene, camera); } renderLoop();先看渲染器参数。antialias 开启抗锯齿,代价是部分低端集显掉帧;setPixelRatio 用 Math.min(window.devicePixelRatio, 2) 是为了挡住 2.5 倍屏那种高分屏把渲染压力翻倍——第 5 章会换成按需渲染,这里是第二版起步的基线。阴影开关 shadowMap.enabled 先打开,后面再用 shadow 参数调细节。相机远近裁面给了 0.1 到 600,适合一间库房的尺度;如果整层楼都是你的场景,这个 far 可以更大但没必要,近裁面太近会影响深度精度。
OrbitControls 的 maxPolarAngle 是俯仰角上限,设成略大于水平线的角度,能让用户俯视密集架但不会穿到地板以下。enableDamping 打开后旋转有惯性,缺点是动画里需要持续调用 controls.update(),这就是为什么 renderLoop 每帧都在更新。第二版的控制器参数建议都放配置对象里,后面接自动巡检相机时好替换。
2.3 灯光与地面:先让密集架看得清再谈美观
3D 库房最怕两件事:一是只有环境光,架子立体感全没;二是阴影到处开,页面卡得动不了。第二版我会先放两个灯:一个 AmbientLight 打底,一个 DirectionalLight 带阴影,后面再决定要不要补半球光。
const ambient = new THREE.AmbientLight(0xffffff, 0.5); scene.add(ambient); const dirLight = new THREE.DirectionalLight(0xffffff, 1.4); dirLight.position.set(12, 20, 10); dirLight.castShadow = true; dirLight.shadow.mapSize.set(2048, 2048); dirLight.shadow.camera.left = -25; dirLight.shadow.camera.right = 25; dirLight.shadow.camera.top = 25; dirLight.shadow.camera.bottom = -25; dirLight.shadow.bias = -0.0005; scene.add(dirLight); const ground = new THREE.Mesh( new THREE.PlaneGeometry(50, 50), new THREE.MeshStandardMaterial({ color: 0x31343e, roughness: 0.85, metalness: 0.05 }) ); ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; scene.add(ground);DirectionalLight 的 shadow.mapSize 决定阴影贴图分辨率,2048 在大场景里够用,4096 会让帧率肉眼可见下降。shadow.camera 的范围要和库房占地大致匹配:范围大于实际模型,阴影边缘会发虚;范围太小,影子走到范围外就消失。bias 设为负值能缓解复杂几何体上的条纹,具体数值看你场景尺度,0.0005 这个量级是常见起点。地面材质 roughness 稍高一些,反光太强在密集架这种金属感物体旁边会显得脏。
灯光和视角都稳定后,下一步才是往场景里放密集架。参数化建模的代码放在第 3 章,那时也会解释万一第一版已经有 GLTF 模型,怎么和这套配置兼容。
3. 3D 密集架参数化建模:用一层数据生成整个档案室
3.1 一列密集架拆解:按部件生成 BoxGeometry
密集架和普通货架的区别在于“密集”二字:多列架子紧贴在一起,中间夹轨道,需要移动某一列才能露出背后的通道。第二版要把这种移动性做出来,建模时每一列必须是独立的对象,而不是整个场景一个 Mesh。先拆解一列:
- 左右立柱:两根竖着的长方体,固定宽度;
- 层板:横向隔板,每层一块;
- 顶板/底板:厚度略厚一点;
- 背板:可加可不加,考虑到移动场景一般不加,否则遮挡视线。
由此得到一个可复用函数:
function createShelfUnit(config) { const group = new THREE.Group(); const frameMat = new THREE.MeshStandardMaterial({ color: 0x3b3f45, roughness: 0.7 }); const shelfMat = new THREE.MeshStandardMaterial({ color: 0x4c7a9c, roughness: 0.6 }); const { width, height, depth, layers } = config; const colGeo = new THREE.BoxGeometry(0.05, height, depth); const leftCol = new THREE.Mesh(colGeo, frameMat); leftCol.position.set(-width / 2 + 0.025, height / 2, 0); group.add(leftCol); const rightCol = leftCol.clone(); rightCol.position.x = width / 2 - 0.025; group.add(rightCol); const plateGeo = new THREE.BoxGeometry(width, 0.06, depth); const top = new THREE.Mesh(plateGeo, shelfMat); top.position.y = height; group.add(top); const bottom = new THREE.Mesh(plateGeo, shelfMat); group.add(bottom); const layerGeo = new THREE.BoxGeometry(width, 0.03, depth); for (let i = 1; i < layers; i++) { const layer = new THREE.Mesh(layerGeo, shelfMat); layer.position.y = (height / layers) * i; group.add(layer); } // 关键:把配置存到 userData,后续点选和档案定位都靠它 group.userData = { id: '', config, type: 'shelfUnit' }; return group; }这段代码是从真实项目里收敛出来的最小版。config 里只有四个参数:width、height、depth、layers,没有把每一层格子的数量也参数化——格子的展示可以用贴图或额外小 Mesh,不影响架体移动逻辑。层板循环从 i=1 到 layers-1,因为顶和底已经单独加了。所有子 Mesh 共用两个材质,不会因为每块板都 new 材质而产生大量材质实例。
一个容易被忽略的细节:leftCol 和 rightCol 用 leftCol.clone() 复制,而不是重新 new Mesh。clone 会共享同一个 Geometry 和 Material,内存开销小很多。如果你后面要给某一列加高亮,就在 clone 之后给材质单独换实例,不要直接改 shared 材质颜色,否则整排架子会一起变色。
3.2 排、列、层、格:用布局数组生成整间库房
单列架子建好后,要把它放进“排”和“列”的坐标系里。档案室里的密集架一般按排分布,一排由若干列组成,列与列之间在移动前是贴着的。第二版的做法是用一个纯数据的布局对象来驱动场景:
const layout = { rows: [ { x: -12, z: -2, direction: 'x', columns: 6, gap: 0.02, // 列与列之间的贴紧间隙 config: { width: 1.2, height: 2.4, depth: 0.6, layers: 6 } }, { x: 12, z: 2, direction: 'x', columns: 6, gap: 0.02, config: { width: 1.2, height: 2.4, depth: 0.6, layers: 6 } } ] }; const shelves = new Map(); // id -> Group layout.rows.forEach((row, rowIndex) => { for (let colIndex = 0; colIndex < row.columns; colIndex++) { const shelf = createShelfUnit(row.config); const g = row.config.width + row.gap; // 每列占用宽度 const offsetX = (colIndex - (row.columns - 1) / 2) * g; shelf.position.set(row.x + offsetX, 0, row.z); shelf.userData.id = `R${rowIndex}C${colIndex}`; shelf.userData.rowIndex = rowIndex; shelf.userData.colIndex = colIndex; scene.add(shelf); shelves.set(shelf.userData.id, shelf); } });这里的布局数据不要写死在代码里,建议从 JS 数组或者后端 JSON 加载。第二版最常见的改进就是把第一版“一行一行手工写 Mesh 位置”改成“读数据现算位置”。用 offsetX = (colIndex - (columns-1)/2) * g 是为了让整排架子以 row.x 为中心对称分布,如果不希望对称,直接把 row.x 当第一列的起点也行。
direction 字段留给了后续扩展——有的档案室密集架沿 z 轴排列,那 offset 就要作用在 z 上。现在只写了 x 方向,但你看到这个字段就知道,第 4 章的档案定位函数也是先判断 direction 再算坐标,不然移动方向全错。这个坑记在第 5 章。
3.3 移动列动画:缓动、轨道限制与并发数量
密集架的开架过程是第二版交互的重头戏。点某一列,这一列带着同一排后面的列一起平移,空出通道。动画不要用最简单的 position.x += speed 线性位移,看久了会觉得生硬;我一般用一个封装好的缓动函数:
const moves = new Map(); // shelf.id -> { startX, targetX, startTime, duration } function moveShelfTo(shelf, targetX, duration = 1200) { moves.set(shelf.userData.id, { startX: shelf.position.x, targetX, startTime: performance.now(), duration }); } function updateMoves() { const now = performance.now(); moves.forEach((move, id) => { const shelf = shelves.get(id); if (!shelf) return moves.delete(id); let t = (now - move.startTime) / move.duration; if (t >= 1) t = 1; // easeInOutQuad const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; shelf.position.x = move.startX + (move.targetX - move.startX) * eased; if (t === 1) moves.delete(id); }); } // animate 循环里调用 updateMoves() function renderLoop() { requestAnimationFrame(renderLoop); updateMoves(); controls.update(); renderer.render(scene, camera); } renderLoop();移动动画只更新 position.x,这就是“轨道限制”——密集架只能在轨道方向平移。不要把移动逻辑写在旋转或 y 轴上,一旦模型旋转过,坐标换算会乱。duration 在开架场景建议 1.2 秒左右,太快会晃,太慢像机械故障。easeInOutQuad 是最稳妥的通用缓动,开头慢、中间快、结尾慢,符合机械平移的视觉直觉。
提示:想要让多列密集架联动移动,先判断 direction,再把整排目标坐标算好,统一设置一组动画,而不是每列单独开动画任务。否则同一帧里列与列之间会出现缝隙,密集架的“密集”感就没了。
3.4 给每列打编号:userData 贯穿整个第二版
第 3.1 节代码里已经放了一行 group.userData。这里专门说明它的用途:点选命中、档案定位、动画回调都要靠 userData 找到“这是哪一列”。密集架信息一般从后端来,格式可能是:
{ "shelfId": "R0C3", "boxCount": 120, "lastMoveAt": "2025-04-10" }前端拿到后把 shelfId 填进 userData.id,再把 boxCount 这类业务字段也放进去。后续渲染 Tooltip 时就不用来回查表,直接从对象上取。用户数据里放什么取决于你们业务,但至少要有 id;避免放函数或大对象,userData 会随场景序列化,内容太大会拉低加载效率。
轨道线我一般用一条贴地的 Box 或 Line 表示,长度与整排架子一致。它有两个作用:视觉上提示用户“架子能沿这条线移动”,也是后面检测移动边界、计算目标坐标的参考。到了第 4 章的档案定位,你会发现所有计算都建立在“这列架子当前在哪个位置、能移到哪个位置”这两个坐标上。
4. 交互、贴图与档案数据绑定:让 3D 档案室能点能查
4.1 Raycaster 点选密集架:命中判定与高亮
交互的基础是 Raycaster。第二版里点击事件必须区分“点到层板”还是“点到整列”,做法是命中任何子 Mesh 后向上找 userData.type === 'shelfUnit' 的父级。不要直接高亮命中的 Mesh,那样只高亮一块板,业务上没意义。
const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); let selectedShelf = null; renderer.domElement.addEventListener('click', (event) => { pointer.x = (event.clientX / window.innerWidth) * 2 - 1; pointer.y = -(event.clientY / window.innerHeight) * 2 + 1; raycaster.setFromCamera(pointer, camera); const hits = raycaster.intersectObjects(shelfGroup.children, true); if (hits.length === 0) { clearSelection(); return; } const hit = hits[0]; let node = hit.object; while (node && node.userData.type !== 'shelfUnit') { node = node.parent; } if (node) selectShelf(node); });raycaster.intersectObjects 的第二个参数传 true 表示递归检测子 Mesh。这里传入的是 shelfGroup.children 而不是 scene,是为了避免点到地面、墙面也触发选择。向上遍历父级找到 shelfUnit 是通用做法:不管命中层板还是立柱,最终都收敛到整列。selectShelf 里要做两件事:高亮这一列,并触发移动或展示对应的业务面板。
高亮不推荐把整个架子的材质都换掉,会触发大量材质实例开销。常见的做法是给这列加一个半透明包围盒 Mesh,比架子略大,用 Emissive 材质;或者只把左右立柱和顶板的材质颜色调亮。我的习惯是加一个外框,原生支持 hover 和 selected 两种状态:hover 时边框淡蓝,选中时边框亮黄。
4.2 CanvasTexture 生成中文标签:贴图不开始可能的原因
密集架每列都要有编号标签,第一版最常见是用图片贴图,结果换电脑就出现“贴图不显示”。第二版直接用 Canvas 画文字再转 CanvasTexture,运行时生成,不依赖外部图片文件。档案编号还是后端返回的数据,前端再画。
function createLabelTexture(text, w = 256, h = 96) { const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#f5f6f8'; ctx.fillRect(0, 0, w, h); ctx.fillStyle = '#1e2126'; ctx.font = `bold ${Math.floor(h * 0.42)}px "Microsoft YaHei", "PingFang SC", sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, w / 2, h / 2); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; texture.minFilter = THREE.LinearFilter; texture.generateMipmaps = false; return texture; } // 使用示例:给每列生成一个贴在顶板正面的标签 Mesh const labelMesh = new THREE.Mesh( new THREE.PlaneGeometry(0.3, 0.12), new THREE.MeshBasicMaterial({ map: createLabelTexture('R0C3'), side: THREE.DoubleSide, transparent: true }) ); labelMesh.position.set(0, 2.3, 0.301); // 贴到架子正面“three.js 贴图开始不显示”是很多人到第二版还在踩的坑。原因基本有两个:一是 three.js 在 r152 之后颜色空间 API 变了,旧代码里 texture.encoding = sRGBEncoding 不生效,纹理不发黑就是发灰,换成 texture.colorSpace = THREE.SRGBColorSpace 即可;二是 CanvasTexture 在 canvas 大小变化或字体未加载完成时生成,纹理内容空白,这时要等页面字体就绪再刷新 texture.needsUpdate = true。上面代码里我特意设置了 generateMipmaps = false,因为标签文字是平面,不需要多级贴图,能省一点显存。
4.3 档案定位:从检索结果到目标坐标
第二版最有价值的点在于“档案定位”——用户在检索框输入档号,前端找到对应的密集架,然后把相机飞过去并高亮那一列。这个功能的坐标计算完全建立在第 3 章的布局数据上:
function getShelfTargetPosition(shelf) { const { rowIndex, colIndex } = shelf.userData; const row = layout.rows[rowIndex]; const g = row.config.width + row.gap; const offsetX = (colIndex - (row.columns - 1) / 2) * g; if (row.direction === 'x') { return new THREE.Vector3(row.x + offsetX, 0, row.z); } return new THREE.Vector3(row.x, 0, row.z + offsetX); } function locateShelf(shelfId) { const shelf = shelves.get(shelfId); if (!shelf) return; const target = getShelfTargetPosition(shelf); const offset = new THREE.Vector3(4, 3, 4); // 相机相对架子的观察偏移 const camTarget = target.clone().add(offset); // 用类似第 3.3 节的缓动思路先移动架子,再移动相机 animateCameraTo(camTarget, 800); selectShelf(shelf); }注意这里的相机动画不是直接瞬移到目标位置。我在实践里通常开了相机的阻尼控制,所以会临时关闭 controls.enableDamping,把相机 position 缓动到目标,同时把 controls.target 也缓动过去,结束后再恢复阻尼。否则动画过程中用户一拖鼠标,相机目标被 OrbitControls 拉回,就会出现“飞到一半弹回原位”的翻车效果。
档案定位还要考虑一件事:检索到的档案可能在某一列的某一层,而不是整列。如果只有列坐标,用户还得自己看这一列哪一层。第二版可以把层信息画在标签或 Tooltip 上:点列之后右侧面板显示“R0C3 / 第4层 / 第2格”,比纯 3D 飞行更实用。3D 里不用真的把每一格建模出来,数据层表达得清楚就行。
4.4 材质与阴影调参:一套能用的参数表
第二版要避免“第一版发灰”,材质调整是最直观的回报。MeshStandardMaterial 在只有 AmbientLight 的情况下会很灰,要靠 DirectionalLight 的高强度补。下面这组是我在类似库房场景里调过一轮的参数,可以直接当起点:
| 参数 | 取值 | 说明 |
|---|---|---|
| AmbientLight intensity | 0.5 | 环境光给整体提亮,太高会冲淡阴影对比度 |
| DirectionalLight intensity | 1.4 | 主光强度,具体看场景尺寸,按需调整 |
| MeshStandardMaterial roughness | 0.6 | 金属感不强,档案架一般亚光喷涂 |
| MeshStandardMaterial metalness | 0.05 | 别用 0.3 以上,反光斑会盖住标签文字 |
| renderer.shadowMap.type | THREE.PCFSoftShadowMap | 第二版推荐,阴影边缘柔和 |
| texture.colorSpace | THREE.SRGBColorSpace | 贴图不显示或发灰的修复点 |
如果你用第一版的旧代码升级到第二版,注意 shadowMap.type 默认是 PCFShadowMap,边缘锯齿比较明显。换 PCFSoftShadowMap 之后阴影柔和,代价是略微变慢。对密集架这种大量小立方体叠在一起的场景,阴影质量比阴影数量重要——宁可只有顶灯一个主光带阴影,也别让环境光里加一堆 castShadow 的点光源。
5. three.js 性能排查与避坑:卡顿、贴图不显示、资源泄漏
5.1 先定位瓶颈:看 FPS、Draw Call 还是内存
如果页面“谷歌网页有 three.js 就卡卡的”,别急着优化模型。先在浏览器 DevTools 的 Rendering 面板打开 FPS 监视器,再用 three.js 的 info 面板抓两个核心数字:renderer.info.render.calls(Draw Call 数)和 renderer.info.memory.geometries(几何体数量)。我见过不少项目卡的原因不是模型面数,而是几千次 Draw Call——每一个小格子一个 Mesh,每次渲染都要切换状态。密集架这种场景,把单列的层板做成合并几何体是第二版优化收益最高的一步。
如果 FPS 正常,但切几次页面后变卡,多半是资源泄漏。Geometry、Material、Texture 都要主动释放,不能指望 GC 回收 WebGL 显存。5.3 节会给清理代码。
5.2 按需渲染:不是所有第二版都要每帧重绘
第一版为了简单,经常 requestAnimationFrame 里每帧 render。第二版几乎都会面临的问题:一个档案室页面大部分时间是静止的,用户只是看、偶尔点,每帧重绘纯属浪费 CPU、GPU。改成按需渲染,能省掉大量功耗,也让交互手感更跟手。
实现方式是在 renderLoop 里用一个 needsRender 标志,只有发生交互、动画或窗口变化时才重绘:
let needsRender = true; controls.addEventListener('change', () => { needsRender = true; }); window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); needsRender = true; }); function renderLoop() { requestAnimationFrame(renderLoop); if (!needsRender) return; updateMoves(); controls.update(); renderer.render(scene, camera); needsRender = false; } renderLoop();这个模式的坑在于:不是所有状态变化都会触发 controls 的 change 事件。第 3.3 节的 updateMoves 在移动密集架时不会自动把 needsRender 置为 true,所以动画开始时要手动设一次;如果用了 Tween 库,要在 onUpdate 回调里置位。窗口 resize 不加监听的话,页面放大缩小后不是变模糊就是只渲染一半,这是按需渲染最常见的翻车点。
5.3 清理纪律:geometry、material、texture 的 dispose
场景要反复切不同的库房、释放上一间房时不要直接把 scene 置空。WebGLRenderer 背后的 Buffer 不会因为你 remove 一个 Mesh 就释放,必须手动调用 dispose。我习惯写一个销毁工具函数:
function disposeObject(root) { root.traverse((child) => { if (!child.isMesh) return; if (child.geometry) child.geometry.dispose(); const materials = Array.isArray(child.material) ? child.material : [child.material]; materials.forEach((mat) => { ['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'emissiveMap', 'aoMap'] .forEach((key) => { const tex = mat[key]; if (tex && tex.isTexture) tex.dispose(); }); mat.dispose(); }); }); } // 切库房前调用 disposeObject(oldRoomGroup); oldRoomGroup.clear(); scene.remove(oldRoomGroup);注意这段代码会遍历材质里常见的那几个纹理槽位,把 map、normalMap 等释放干净。但同一个纹理如果被多个材质共享,不要每个材质都 dispose,否则第二次释放的是悬空纹理,会报错。方式是维护一个 Set,记录已经 dispose 过的纹理,重复出现就跳过。第二版项目规模一大,资源清理这块是稳定性的分水岭——不释放内存,切几次库房页面必崩。
5.4 避坑记录:四条实操中踩过的坑
坑一:贴图颜色发暗或不显示
现象:CanvasTexture 或外部贴图在页面上要么是黑色的,要么整体发灰,重刷一次才好。
原因:three.js r152 之后颜色空间 API 改变,旧代码的 texture.encoding = THREE.sRGBEncoding 被移除,新版本必须设置 texture.colorSpace = THREE.SRGBColorSpace;另外 CanvasTexture 在 canvas 未完成绘制时创建会捕获空白帧。
解决:纹理创建后立即设 colorSpace,并把 generateMipmaps = false、minFilter 设成 LinearFilter。字体加载后再 map.needsUpdate = true。
坑二:密集架阴影出现条纹或闪烁
现象:放置密集架后,立柱和层板接触的位置出现黑色条纹,旋转视角时条纹忽闪。
原因:阴影贴图存在浮点精度问题。DirectionalLight 的 shadow.camera 范围太大,或 shadow.bias 为默认正值,导致自阴影错误。
解决:把 dirLight.shadow.camera.left/right/top/bottom 调到刚好包裹库房尺寸,shadow.bias 设负值如 -0.0005,shadow.mapSize 用 2048 而不是一上来就上 4096。
坑三:点击一列架子却选到背后的列
现象:密集架排列紧密,点前面一列的层板,Raycaster 返回的命中结果是后面一列的对象。
原因:intersectObjects 返回的 hits 按距离排序,但层板很薄、相邻列间隙只有 0.02,相机角度低时射线可能先扫到后排层板的背面。
解决:方向过滤——命中对象所在列的朝向与视线夹角偏大时忽略,或者把命中检测只限制在 camera 朝向的可见侧;更稳妥的是点击时从命中对象向上找 userData.type 再判断当前列是否已展开,如果未展开且被别的列遮挡就不响应点选,避免误操作。
坑四:连续切换库房后内存持续上涨
现象:每隔几分钟切换一次场景,任务管理器里浏览器内存稳步上升,最后掉帧、白屏。
原因:旧场景的几何体、纹理没有 dispose;共享纹理被同一材质多次释放还可能抛错。
解决:用 5.3 节的 disposeObject,并给共享纹理维护 release Set。切换前先移除动画(clear moves),再清空 Raycaster 的引用,最后再 dispose。
6. 验证与进阶:用调试面板和渲染档位把第二版做实
6.1 用调试面板验证第二版:快速调参的性价比
第二版代码量一大,直接改源码测参数效率很低。我习惯在开发模式里接一个 lil-gui 这类调试面板,把密集架的架宽、层数、移动速度、阴影开关全部暴露出来,拖一下滑块就改布局数据并重建架体。这个做法帮我避开了大量“改一行刷新一次”“调一次阴影等三十秒”的低效调试。记得给面板加一个“重置”,回到配置里的默认值。
6.2 三档渲染配置:静态浏览、交互操作、演示录制
落地时不要只留一套渲染策略,至少分三档。静态浏览和演示录制都可以不开实时阴影,交互操作再开,保证交付后的页面在普通电脑上不卡。
| 档位 | 场景 | 阴影 | 渲染策略 |
|---|---|---|---|
| 静态浏览 | 展示库房整体布局 | 关闭 | 按需渲染,仅 camera change 触发 |
| 交互操作 | 点选、开架、档案定位 | 开启 PCFSoft | 按需渲染 + 动画触发 |
| 演示录制 | 录屏、大屏展示 | 可关可低分辨率 | 每帧渲染,保证画面连续性 |
档位切换不用重载页面,把配置对象作为唯一数据源,改完重建场景即可。这个模式让我在交付后处理过不少“客户电脑是集成显卡”的突发情况——把静态档位一开,项目照样能演示。
我做这种第二版改造,最后的习惯是:先定一份可回退的配置文件,把架宽、层数、移动速度全部提出来,任何一次改参数都能从配置回退。这个习惯让我少熬了好几个晚上。如果你也要把第一版翻成第二版,建议先把这份配置和避坑清单放桌面上,再动手改模型。希望帮到你。
本文还有配套的精品资源,点击获取