Blender + Three.js 开发讲解:从 .blend 到能转的产品页

先把话说在前面

上周三下午,同事把 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 需求是什么样的

场景很典型:卖头戴耳机的电商详情页,用户要能自己配颜色和材质,能看内部结构,能存一张图发给朋友。

拆成四条:

  1. 五个颜色、三种材质(皮革、织物、金属),实时切换
  2. 一个"爆炸图"滑杆,把外壳、头梁、耳罩、单元拉开
  3. 三个相机预设:整体、正面、细节
  4. 一键截图,导出 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()​​,先花半小时把单位、朝向、原点、命名这四件事跟建模的人对齐。这半小时的投入产出比,比你后面调一周光照都高。

相关推荐
惊讶的猫1 小时前
Spring AOP
java·开发语言
ii_best2 小时前
按键精灵手机助手 v5.2.1 实测:inputText 逐字延时 + 调试面板多行复制,安卓脚本开发的两个输入痛点一次解决
android·开发语言·智能手机·自动化·ai编程·按键精灵
波力海苔夹心脆6752 小时前
C# StringBuilder 详解:字符串拼接为什么总说要用它?
开发语言·笔记·c#·.net·字符串·stringbuilder·入门教程
程序员大阳2 小时前
副队长JavaScript教程(20)–三元运算符、逗号运算符
javascript·三元运算符·逗号运算符
Wang's Blog2 小时前
Java 项目实战: 交友项目 - 今日佳人:需求分析
java·开发语言·交友
老孟说禅2 小时前
自动化测试入门(一)
开发语言·python
余额瞒着我当琳2 小时前
C++11,发展历史浅谈,初始化列表和对象初始化,右值引用和移动语义
java·开发语言·c++·算法
学逆向的2 小时前
C 宏函数
c语言·开发语言·网络安全