先把话说在前面
上周三下午,同事把 300 多兆的 .blend 甩过来,附了一句"模型做好了,你看看能不能放网页上"。
我当时正在改一个表单校验,抬头看了眼那个文件大小,心里咯噔一下。这不是第一次干这事了。上一回是把一个沙发模型塞进落地页,首屏白屏等了六秒多,运营第二天就拿着数据来找我。
所以这回我换了个做法:不急着往页面里塞模型,而是先把整条流水线定下来。Blender 负责"东西长什么样",Three.js 负责"东西怎么动",中间用一个 .glb 交接。这条线一旦定清楚,后面 90% 的返工都能省掉。
下面就是这条线,包括我踩过的坑。
一、这两个东西到底各管什么
很多人对分工的理解是模糊的,觉得 Blender 是"建模软件",Three.js 是"3D 库",剩下的靠感觉。其实两者之间有一条很硬的分界线。
Blender 这边管:几何体本身、UV 展开、拓扑密度、材质的基础参数、贴图烘焙、物体命名、朝向、原点位置、LOD 分级。这些都属于"资产的物理属性",一旦定下来就不该在代码里改。
Three.js 这边管:加载、光照环境、相机、交互、动画、后处理、性能预算、以及所有跟用户行为相关的东西。
越界的事谁做谁痛苦。我见过最典型的翻车现场,是有人嫌 Blender 里调材质麻烦,导出一个纯白模型,然后在 Three.js 里手写一堆 MeshStandardMaterial 参数去"还原"设计稿。结果颜色永远差一点,因为一边是 sRGB 输出,一边是线性空间计算,你在跟色彩管理打架,赢不了。
还有一种是反过来的:前端同学为了省事,把整个场景的灯光、相机、甚至动画都烘进 Blender,导出成一段固定视角的序列帧。那确实"能看",但用户连转都转不了,3D 的意义就没了。
一句话:Blender 决定画面的上限,Three.js 决定体验的下限。
二、整条链路长什么样
先看全景。这条链路一共五段,前三段在 Blender 里,后两段在浏览器里。

我特别想说的是底下那条红色虚线。它看起来像流程图的装饰,实际上是最常走的一条路。你导出、加载、一看效果不对,然后回炉。第一次合作的时候这个循环能来回三四次,熟练之后基本一次过,但别指望能彻底消灭它。
能减少回头次数的办法只有一个:在 Blender 里就把浏览器该知道的约束定死。单位、朝向、原点、命名、材质数量,这五件事在建模阶段花十分钟确认,能省掉后面两天。
三、Blender 侧:从第一笔就开始为浏览器着想
3.1 单位、朝向、原点,三件小事
先说单位。Blender 默认 1 个单位等于 1 米,但国内做工业模型的人习惯按毫米或厘米建。你按厘米建了一个 20 厘米宽的耳机,导出后 Three.js 认为它是 20 米宽,相机得退到八十米外才能拍全。新手第一反应是"我的模型怎么没了",其实是它大到把相机包进去了。
再说朝向。这个坑几乎人人踩一次:

Blender 是 Z 轴朝上,Three.js 是 Y 轴朝上。导出面板里有个 +Y Up 选项,勾上就自动帮你转。忘了勾,模型进去就是躺着的。你以为是相机写错了,调了半天 camera.lookAt,最后发现是模型在躺着看你。
第三个是原点。建模的时候随手在场景里一放,原点可能在模型脚底,也可能在离模型两百米远的某个角落。前端拿到之后做旋转、吸附、镜头对焦,全都在绕一个莫名其妙的位置转。原点应该在包围盒底部的正中心,这是最通用的约定。
3.2 命名,别用 Cube.001
这条我要单独拎出来说,因为它直接影响代码怎么写。
前端在 Three.js 里找零件,靠的是 object.name。如果你的场景里是 Cube、Cube.001、Sphere.012、Mesh_034,那前端只能靠遍历顺序猜哪个是外壳、哪个是耳罩。改一次模型,顺序一变,代码全崩。
我现在的约定是 材质名__零件名,比如 leather__shell、metal__driver。这样前端既能按零件找,也能按材质分组批量处理。
3.3 材质和贴图:能烘就烘
浏览器里跑 PBR 是实时的,但"实时"不等于"免费"。每多一个材质,就多一次状态切换;每多一张 4K 贴图,就多十几兆显存。
我的做法是把复杂的程序化材质在 Blender 里烘成三张图:基础色、法线、粗糙度(有时再加一张 AO)。烘焙之后材质树被拍平成贴图,前端拿到就是标准 PBR,渲染成本可控,效果也可控。
贴图尺寸别迷信 4K。一个在页面上只占 300 像素宽的耳机,2K 贴图已经完全看不出区别,1K 也够用。我一般给主体 2K,小零件 1K 甚至 512。
3.4 一段能直接跑的脚本
手动做上面这些事,一个模型要二十多分钟,十个模型就是一上午。所以我把它们写成脚本,挂在命令行里跑。
# blender/export_assets.py
# 用法:blender -b product.blend -P export_assets.py
import bpy
import os
SRC = bpy.data.filepath
OUT = os.path.join(os.path.dirname(SRC), "export")
os.makedirs(OUT, exist_ok=True)
def clean_scene():
"""清掉灯光、相机、空物体,这些不该出现在 GLB 里"""
for ob in list(bpy.data.objects):
if ob.type in {"LIGHT", "CAMERA", "EMPTY", "SPEAKER"}:
bpy.data.objects.remove(ob, do_unlink=True)
# 顺手清掉孤立数据块,能明显减小文件体积
bpy.ops.outliner.orphans_purge(do_recursive=True)
def apply_transforms():
"""把旋转和缩放烘进网格数据,避免前端拿到非 1 的 scale"""
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
def set_origin_to_bottom(ob):
"""原点挪到包围盒底部中心,前端做旋转和吸附时最省事"""
bpy.context.view_layer.objects.active = ob
bpy.ops.object.origin_set(type="ORIGIN_GEOMETRY", center="BOUNDS")
ob.location.z -= ob.dimensions.z / 2
def rename_by_material():
"""命名成 材质名__零件名,前端按名字找零件"""
for ob in bpy.data.objects:
if ob.type != "MESH" or not ob.material_slots:
continue
mat = ob.material_slots[0].material
if not mat:
continue
base = ob.name.split(".")[0]
ob.name = f"{mat.name}__{base}"
def export(name):
bpy.ops.export_scene.gltf(
filepath=os.path.join(OUT, f"{name}.glb"),
export_format="GLB",
export_apply=True, # 应用修改器
export_yup=True, # Z-up 转 Y-up,这条最关键
export_draco_mesh_compression_enable=True,
export_draco_mesh_compression_level=6,
export_texcoords=True,
export_normals=True,
export_tangents=False, # 不用法线贴图就关掉,能省 20%~30% 顶点数据
export_materials="EXPORT",
export_cameras=False,
export_lights=False,
)
if __name__ == "__main__":
clean_scene()
apply_transforms()
for obj in [o for o in bpy.data.objects if o.type == "MESH"]:
set_origin_to_bottom(obj)
rename_by_material()
export("product")
print("导出完成 ->", OUT)
blender -b product.blend -P export_assets.py,一条命令出包。建模师改完模型自己跑一遍就行,不用等前端。
四、导出:GLB 不是唯一答案,但通常是最省事的
.gltf 是一套文本格式,配合外部 .bin 和一堆贴图文件,路径管理很容易出错。.glb 是把所有东西打进一个二进制包,一个文件搞定,CDN 也友好。除非你有特殊的分包需求,我建议直接用 GLB。
压缩这块有两个选择,别搞混:
|--------------|----------|--------------|-------------------|
| 方案 | 压缩对象 | 典型收益 | 代价 |
| Draco | 几何体顶点数据 | 体积降 60%~90% | 需要加载解码器,解码有几十毫秒开销 |
| meshopt | 几何体 + 动画 | 体积降 40%~70% | 解码更快,但生态支持略少 |
| KTX2 / Basis | 贴图 | 显存降 70% 左右 | 需要转码器,首次加载稍慢 |
| 直接降贴图分辨率 | 贴图 | 立竿见影 | 画质损失,要肉眼验一遍 |
我的默认组合是 Draco(level 6)+ KTX2 贴图 + gzip。这套组合下来,一个 12.4MB 的耳机包能压到 1.8MB 左右,而且画质肉眼看不出差别。
有一点要注意:Draco 的解码是在 Web Worker 里跑的,主线程不会卡。这也是我选它而不是自己写压缩的原因------省心。
五、Three.js 侧:加载这一段,比想象中复杂
很多人以为加载就是一行 loader.load(url, onLoad)。实际项目里,这段代码要处理的东西比渲染循环还多。

看这张时序图,有几点值得单独说:
进度条不能是假的。 很多实现里进度条是"定时器走到 90% 然后等加载完跳 100%",用户能看出来。GLTFLoader 的 onProgress 给的是真实的下载进度,虽然它只覆盖下载阶段、不覆盖解析阶段,但至少前 70% 是真的。
解析完成不等于能看。 模型解析完,纹理还要上传到 GPU,shader 还要编译。这两步第一次发生时会有明显卡顿,通常几百毫秒。解决办法是在加载完成后先 renderer.compile(scene, camera) 预热一遍,把编译开销提前到 loading 遮罩还在的时候。
用完的解码器要释放。 DRACOLoader 和 KTX2Loader 内部会创建 Worker 和 WASM 实例,加载完不 dispose(),切几个页面内存就上去了。
封装一下大概长这样:
// src/loader.js
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
export async function loadProduct(renderer, url, onProgress) {
const draco = new DRACOLoader().setDecoderPath('/draco/');
const ktx2 = new KTX2Loader()
.setTranscoderPath('/basis/')
.detectSupport(renderer); // 让 KTX2 自己挑最合适的 GPU 格式
const loader = new GLTFLoader()
.setDRACOLoader(draco)
.setKTX2Loader(ktx2);
const gltf = await loader.loadAsync(url, (e) => {
if (e.lengthComputable) onProgress?.(e.loaded / e.total);
});
// 色彩管理只在这里设一次,别散落在各处
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
gltf.scene.traverse((obj) => {
if (!obj.isMesh) return;
obj.castShadow = true;
obj.receiveShadow = true;
if (obj.material?.map) obj.material.map.anisotropy = 8;
});
// 解码器用完就放,Worker 不会一直挂着
draco.dispose();
ktx2.dispose();
return gltf;
}
anisotropy = 8 这行值得单独提一句。模型上的贴图一旦被斜着看,比如俯视一个圆柱体的侧面,没有各向异性过滤就会糊成一片。这个参数对观感的提升,比多加一倍面数明显得多,成本几乎为零。
六、性能:先看数字,再谈优化
优化这件事最怕凭感觉。我习惯先跑一遍 renderer.info,把 DrawCall、三角形数、纹理数量记下来,改完再跑一遍对比。

这几个数字背后是四个不同的手段:
包体积从 12.4MB 到 1.8MB,靠的是 Draco 加 KTX2 再加 gzip。这一项对首屏时间的影响最大,因为它直接决定了用户要等多久。
DrawCall 从 318 次降到 24 次 ,靠的是两件事:一是把同类零件的材质合并成共享实例,二是对重复出现的零件(比如左右两个耳罩上的螺丝)用 InstancedMesh。DrawCall 是 CPU 侧的开销,移动端尤其敏感,超过 100 次就该警惕了。
首帧时间从 6.2 秒到 1.1 秒,除了体积变小,还有一招是把非关键零件延后加载。先出一个低模占位,等用户开始交互了再换高模。
移动端帧率从 22 到 58 ,主要是关掉了阴影的高清贴图(2048 降到 1024)、限制了 pixelRatio 上限为 2、以及给远景零件加了 LOD。
还有一个不太显眼但很有用的开关:frustumCulled。Three.js 默认会剔除视锥体外的物体,但如果你的模型是一个整体 mesh,剔除粒度就很粗。拆成零件之后,转到背面的零件会自动被剔除,能白捡一点性能。
七、完整实战:一个耳机定制配置器
前面都是零件,现在装成一台机器。
7.1 需求是什么样的
场景很典型:卖头戴耳机的电商详情页,用户要能自己配颜色和材质,能看内部结构,能存一张图发给朋友。
拆成四条:
- 五个颜色、三种材质(皮革、织物、金属),实时切换
- 一个"爆炸图"滑杆,把外壳、头梁、耳罩、单元拉开
- 三个相机预设:整体、正面、细节
- 一键截图,导出 PNG
7.2 结构怎么分
我踩过的最大一个坑,是把 3D 逻辑写进按钮的点击事件里。三个按钮还行,到第十个的时候,改一个颜色要动七八处代码,而且状态永远对不上------UI 显示的是红色,模型还是蓝色。
后来改成了四层:

核心原则只有一条:UI 只改状态,3D 只订阅状态,两边不直接说话。
这样做的直接好处是"截图分享"这个需求变得特别简单------只要把状态序列化成 URL 参数,别人打开链接就是同一套配置。要是 3D 逻辑散在按钮里,这个功能基本没法做。
7.3 完整代码
index.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>耳机定制 · 3D 配置器</title>
<style>
:root { --ink: #2b2b2b; --line: #e2ddd2; }
* { box-sizing: border-box; }
body {
margin: 0; background: #faf8f4; color: var(--ink);
font: 15px/1.6 -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
}
.stage { position: relative; height: 62vh; min-height: 380px; background: #f3efe6; }
#scene { display: block; width: 100%; height: 100%; }
.progress {
position: absolute; left: 0; bottom: 0; height: 3px; width: 0;
background: #c0453c; transition: width .2s ease;
}
.panel { padding: 18px 22px; display: grid; gap: 16px; }
.row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.swatch {
width: 28px; height: 28px; border-radius: 50%; cursor: pointer;
border: 2px solid transparent; padding: 0;
}
.swatch[aria-pressed="true"] { border-color: var(--ink); }
button {
font: inherit; padding: 7px 14px; cursor: pointer;
border: 1px solid var(--line); border-radius: 999px; background: #fff;
}
button[aria-pressed="true"] { background: var(--ink); color: #fff; border-color: var(--ink); }
input[type="range"] { width: 180px; }
</style>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.169.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.169.0/examples/jsm/"
}
}
</script>
</head>
<body>
<div class="stage">
<canvas id="scene"></canvas>
<div class="progress"></div>
</div>
<div class="panel">
<div class="row" id="colors"></div>
<div class="row" id="materials"></div>
<div class="row">
<label for="explode">爆炸图</label>
<input id="explode" type="range" min="0" max="1" step="0.01" value="0" />
<button id="shot">存一张图</button>
</div>
<div class="row" id="presets"></div>
</div>
<script type="module" src="./configurator.js"></script>
</body>
</html>
configurator.js:
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { loadProduct } from './loader.js';
/* ---------- 1. 状态:唯一数据源 ---------- */
const state = { color: '#2f3a46', material: 'leather', explode: 0, preset: 'hero' };
const listeners = new Set();
function setState(patch) {
Object.assign(state, patch);
listeners.forEach((fn) => fn(state));
}
function subscribe(fn) {
listeners.add(fn);
fn(state);
}
/* ---------- 2. 场景 ---------- */
const canvas = document.getElementById('scene');
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
preserveDrawingBuffer: true, // 截图要用
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
scene.background = new THREE.Color('#f3efe6');
const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 80);
camera.position.set(2.4, 1.6, 3.2);
const controls = new OrbitControls(camera, canvas);
controls.target.set(0, 0.45, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.minDistance = 1.4;
controls.maxDistance = 6;
controls.maxPolarAngle = Math.PI * 0.49; // 别让相机钻到地面下面
// 用现成的房间环境烘 IBL,比手搭三点光稳定,也不用调
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
scene.environmentIntensity = 0.9;
const keyLight = new THREE.DirectionalLight(0xffffff, 2.0);
keyLight.position.set(3, 5, 4);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(1024, 1024);
keyLight.shadow.bias = -0.0008;
scene.add(keyLight);
// 只接收阴影的地面,比画一个真实地平面省事
const ground = new THREE.Mesh(
new THREE.CircleGeometry(3.4, 64),
new THREE.ShadowMaterial({ opacity: 0.18 })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
/* ---------- 3. 零件表 ---------- */
const parts = new Map();
const SWAPPABLE = new Set(['shell', 'band', 'cushion']); // 只有这些零件换材质
// 爆炸方向:每个零件往哪边飞
const EXPLODE = {
shell: [0, 0.3, 0],
band: [0.4, 0.12, 0],
cushion: [0, -0.24, 0.34],
driver: [0, 0.04, -0.42],
};
/* ---------- 4. 材质 ---------- */
const TEXTURES = {
leather: { map: '/tex/leather_albedo.jpg', normal: '/tex/leather_normal.jpg', roughness: 0.62, metalness: 0 },
fabric: { map: '/tex/fabric_albedo.jpg', normal: '/tex/fabric_normal.jpg', roughness: 0.92, metalness: 0 },
metal: { map: null, normal: null, roughness: 0.26, metalness: 1 },
};
const texLoader = new THREE.TextureLoader();
const texCache = new Map();
function loadTexture(url, srgb = false) {
if (!url) return null;
const key = `${url}|${srgb}`;
if (!texCache.has(key)) {
const t = texLoader.load(url);
// 只有颜色贴图用 sRGB,法线/粗糙度必须是线性
t.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace;
t.wrapS = t.wrapT = THREE.RepeatWrapping;
t.anisotropy = 8;
texCache.set(key, t);
}
return texCache.get(key);
}
const matCache = new Map();
function getMaterial(preset, color) {
const key = `${preset}|${color}`;
if (matCache.has(key)) return matCache.get(key);
const cfg = TEXTURES[preset];
const mat = new THREE.MeshStandardMaterial({
color: new THREE.Color(color),
map: loadTexture(cfg.map, true),
normalMap: loadTexture(cfg.normal, false),
roughness: cfg.roughness,
metalness: cfg.metalness,
});
matCache.set(key, mat);
return mat;
}
/* ---------- 5. 相机预设 ---------- */
const PRESETS = {
hero: { pos: new THREE.Vector3(2.4, 1.6, 3.2), target: new THREE.Vector3(0, 0.45, 0) },
front: { pos: new THREE.Vector3(0, 0.62, 4.2), target: new THREE.Vector3(0, 0.45, 0) },
detail: { pos: new THREE.Vector3(0.85, 0.95, 1.35), target: new THREE.Vector3(0.22, 0.55, 0.1) },
};
let tween = null;
function flyTo(name) {
const p = PRESETS[name];
if (!p) return;
tween = {
fromPos: camera.position.clone(),
fromTarget: controls.target.clone(),
toPos: p.pos.clone(),
toTarget: p.target.clone(),
t: 0,
};
}
/* ---------- 6. 加载模型 ---------- */
const gltf = await loadProduct(renderer, '/models/product.glb', (p) => {
document.querySelector('.progress').style.width = `${Math.round(p * 100)}%`;
});
gltf.scene.traverse((obj) => {
if (!obj.isMesh) return;
obj.castShadow = true;
obj.receiveShadow = true;
// Blender 里命名成 材质名__零件名,这里直接切
const [, partName] = obj.name.split('__');
parts.set((partName || obj.name).toLowerCase(), {
mesh: obj,
base: obj.position.clone(), // 记下原始位置,爆炸图要还原
});
});
scene.add(gltf.scene);
/* ---------- 7. 订阅 ---------- */
let lastPreset = state.preset;
let currentExplode = 0;
subscribe((s) => {
const mat = getMaterial(s.material, s.color);
for (const [name, part] of parts) {
if (SWAPPABLE.has(name)) part.mesh.material = mat; // 共享同一个实例
}
// 只有 preset 变了才飞相机,不然改个颜色镜头也跟着动
if (s.preset !== lastPreset) {
lastPreset = s.preset;
flyTo(s.preset);
}
});
// 预热:把所有材质和 shader 先编译一遍,切的时候不卡
for (const preset of Object.keys(TEXTURES)) getMaterial(preset, state.color);
renderer.compile(scene, camera);
document.querySelector('.progress').style.width = '100%';
/* ---------- 8. UI 绑定 ---------- */
const COLORS = ['#2f3a46', '#8c4a3f', '#c9b28a', '#e8e3d9', '#5d6f5a'];
const colorBox = document.getElementById('colors');
COLORS.forEach((c) => {
const b = document.createElement('button');
b.className = 'swatch';
b.style.background = c;
b.setAttribute('aria-pressed', String(c === state.color));
b.onclick = () => {
[...colorBox.children].forEach((el) => el.setAttribute('aria-pressed', 'false'));
b.setAttribute('aria-pressed', 'true');
setState({ color: c });
};
colorBox.appendChild(b);
});
const MATS = [['leather', '皮革'], ['fabric', '织物'], ['metal', '金属']];
const matBox = document.getElementById('materials');
MATS.forEach(([id, label]) => {
const b = document.createElement('button');
b.textContent = label;
b.setAttribute('aria-pressed', String(id === state.material));
b.onclick = () => {
[...matBox.children].forEach((el) => el.setAttribute('aria-pressed', 'false'));
b.setAttribute('aria-pressed', 'true');
setState({ material: id });
};
matBox.appendChild(b);
});
const presetBox = document.getElementById('presets');
[['hero', '整体'], ['front', '正面'], ['detail', '细节']].forEach(([id, label]) => {
const b = document.createElement('button');
b.textContent = label;
b.onclick = () => setState({ preset: id });
presetBox.appendChild(b);
});
document.getElementById('explode').oninput = (e) => {
setState({ explode: Number(e.target.value) });
};
document.getElementById('shot').onclick = () => {
renderer.render(scene, camera); // preserveDrawingBuffer 保证这里能取到像素
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = `config-${state.material}-${state.color.slice(1)}.png`;
a.click();
};
/* ---------- 9. 渲染循环 ---------- */
function resize() {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
if (!w || !h) return;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
new ResizeObserver(resize).observe(canvas);
resize();
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
const dt = Math.min(clock.getDelta(), 0.05);
// 爆炸图做插值,滑杆拖动时才不会一跳一跳
currentExplode += (state.explode - currentExplode) * Math.min(1, dt * 10);
for (const [name, part] of parts) {
const d = EXPLODE[name] || [0, 0, 0];
part.mesh.position.set(
part.base.x + d[0] * currentExplode,
part.base.y + d[1] * currentExplode,
part.base.z + d[2] * currentExplode
);
}
// 相机缓动,用 easeOutCubic 收尾更自然
if (tween) {
tween.t = Math.min(1, tween.t + dt * 2.2);
const k = 1 - Math.pow(1 - tween.t, 3);
camera.position.lerpVectors(tween.fromPos, tween.toPos, k);
controls.target.lerpVectors(tween.fromTarget, tween.toTarget, k);
if (tween.t >= 1) tween = null;
}
controls.update();
renderer.render(scene, camera);
});
/* ---------- 10. 卸载 ---------- */
export function dispose() {
renderer.setAnimationLoop(null);
controls.dispose();
scene.traverse((obj) => {
if (!obj.isMesh) return;
obj.geometry.dispose();
});
texCache.forEach((t) => t.dispose());
matCache.forEach((m) => m.dispose());
pmrem.dispose();
renderer.dispose();
}
7.4 三个值得说的细节
第一个是材质共享。 代码里 part.mesh.material = mat 赋的是同一个材质实例,不是克隆。三个零件共用一份材质,Three.js 就能把它们合批处理。如果你写成 mat.clone(),DrawCall 立刻翻三倍,效果一模一样但帧率掉一半。
第二个是 lastPreset 那个判断。 我第一版没写,结果每次点颜色,镜头都会"嗖"地飞回默认角度。因为 subscribe 的回调里读的是完整的 state,preset 字段一直在那儿,就一直在触发。这种 bug 很隐蔽,因为功能"看起来是好的"。
第三个是 Math.min(1, dt * 10)。 直接 lerp(a, b, 0.1) 的问题是跟帧率绑定,60 帧和 120 帧的设备手感不一样。乘上 dt 之后就跟时间对齐了,掉帧的时候也不会突然跳一大截。
八、踩过的坑,按踩到的顺序

这六个坑我全踩过,其中第三个(原点不在脚下)最阴险。表现是"模型转起来有点歪",你会以为是相机或者 controls.target 的问题,调一下午,最后发现是原点在离模型一米远的地方。
第四个(贴图发灰)也很常见。判断方法很简单:如果画面整体像蒙了一层灰雾、暗部不黑,八成是颜色贴图没设 SRGBColorSpace;如果法线贴图也按 sRGB 处理了,光照会整个错乱,表现为"高光位置不对"。
第五个和第六个是纯代码问题,靠 review 和 renderer.info 能查出来。养成习惯:每加一个场景切换,就在控制台打一次 renderer.info.memory,数字只涨不跌,就是泄漏了。
九、上线前的一张清单
我把这套流程固化成了一张清单,每次上线前过一遍,能挡掉大部分事故:
|----------|----------------------------------------|
| 检查项 | 合格标准 |
| 包体积 | 单模型 GLB 小于 3MB,首屏可交互资源小于 1MB |
| DrawCall | 桌面端小于 150,移动端小于 80 |
| 首帧 | 4G 网络下小于 2.5 秒 |
| 移动端帧率 | 稳定 50fps 以上 |
| 内存 | 切换五次场景后 renderer.info.memory 不增长 |
| 降级 | WebGL 不可用时有一个静态图兜底 |
| 无障碍 | canvas 有 aria-label,关键信息有文字版 |
最后一条经常被忽略。3D 内容对屏幕阅读器是完全不可见的,如果配置器里所有信息都只存在于画面上,那对一部分用户来说这个页面等于不存在。加一段文字版的配置摘要,成本很低。
这套东西听起来步骤不少,但真正跑顺之后,从建模师交文件到页面上线,一个模型大概两小时。相比第一次做的三天,差别全在"约束前置"这四个字上。
Blender 和 Three.js 之间那条线,本质上是一条信息损耗线。你在 Blender 里多做一步规范化,前端就少写一百行补救代码。反过来,前端每多写一行"猜测模型意图"的逻辑,都是在给未来的自己挖坑。
所以如果你正准备做第一个 3D 页面,我的建议是:别急着写 scene.add(),先花半小时把单位、朝向、原点、命名这四件事跟建模的人对齐。这半小时的投入产出比,比你后面调一周光照都高。