阅读视图

发现新文章,点击刷新页面。

【Three.js内存管理】那些你以为释放了,其实还在占着的资源

前言

你以为你 dispose 了,它就没了吗?Too young ~

三个月前,我差点被一个 Bug 搞到怀疑人生。

事情是这样的:我负责的一个智慧园区项目,上线前测试同学跑过来说:“页面打开久了会卡,你瞅瞅?”

我打开页面,刚开始确实丝滑,60fps 稳稳的。然后我开始疯狂切页面、关弹窗、加载新模型……五分钟后再看帧率,30fps。十分钟后,15fps。二十分钟后,页面直接白屏,Chrome 弹出一个熟悉的提示:

“喔唁,崩溃啦。”

我懵了。

代码里明明写了 dispose(),该释放的都释放了,怎么还能崩?打开 Chrome 任务管理器一看,GPU 内存那一栏的数字,像坐了火箭一样往上涨,根本停不下来。

那天下午,我干了一件事:把所有以为释放了、实际还占着的资源,一个个揪出来。今天就把这些“装死”的资源全曝光,省得你们也踩坑。


第一个坑:几何体,你 dispose 了吗?

先看一段我当时的代码:

// 加载一个模型
loader.load('big-model.glb', (gltf) => {
  const model = gltf.scene;
  scene.add(model);
});

// 后来某个时刻,移除模型
scene.remove(model);
// 心想:移除就完事儿了,内存会自动释放吧?

天真的我,以为 remove 就万事大吉。结果呢?几何体数据还赖在 GPU 里不走。

正确做法

// 移除前,遍历模型,dispose 所有几何体和材质
function disposeModel(model) {
  model.traverse((obj) => {
    if (obj.isMesh) {
      if (obj.geometry) {
        obj.geometry.dispose();
      }
      if (obj.material) {
        if (Array.isArray(obj.material)) {
          obj.material.forEach(m => m.dispose());
        } else {
          obj.material.dispose();
        }
      }
    }
  });
  scene.remove(model);
}

你以为这就够了?太天真了。材质里的纹理呢?你不 dispose,它还在!


第二个坑:纹理,你 dispose 了吗?

材质 dispose 只会释放材质本身的 GPU 资源,但纹理是单独分配的。你得手动把纹理也干掉。

// 错误:只 dispose 材质
material.dispose(); // 纹理还在!

// 正确:先 dispose 纹理
if (material.map) material.map.dispose();
if (material.normalMap) material.normalMap.dispose();
if (material.roughnessMap) material.roughnessMap.dispose();
// ... 还有 aoMap、emissiveMap、metalnessMap ...
material.dispose();

有一次我忘了 dispose 纹理,结果加载了 100 个不同的模型,每个模型都带一张 4K 贴图。你们猜 GPU 内存用了多少?直接爆了,页面黑屏。

更坑的是,有些纹理是多个材质共用的。如果你 dispose 了共用纹理,其他材质也跟着完蛋。所以必须做好引用计数,或者用 ResourceTracker 统一管理。


第三个坑:RenderTarget,你不 dispose 试试?

做后期处理的时候,经常用到 WebGLRenderTarget。比如 ping-pong buffer、阴影贴图、反射纹理……

const rt = new THREE.WebGLRenderTarget(1024, 1024);
// 用完之后,忘了 dispose

这个玩意儿,不 dispose 的话,显存占用一直不释放。而且你肉眼看不见,Chrome 任务管理器里 GPU 内存悄悄上涨。

正确:用完就扔。

rt.dispose();

特别是做动态效果,每帧新建一个 RenderTarget 又不释放,那内存涨得比股票还快。


第四个坑:InstancedMesh 的矩阵,你以为删了就没了?

InstancedMesh 是个好东西,能把成千上万个实例压缩成一个 Draw Call。但如果你动态增删实例,得小心。

// 创建
const instancedMesh = new THREE.InstancedMesh(geo, mat, 1000);
scene.add(instancedMesh);

// 后来想删掉一部分实例,直接把 count 改小?
instancedMesh.count = 500;
// 你以为剩下的 500 个实例的内存就释放了?

图样图森破InstancedMesh 内部的矩阵缓冲区(instanceMatrix)还是 1000 的大小,只是渲染时只画前 500 个。那 500 个被“删掉”的实例数据还占着显存。

正确做法:重新创建一个新的 InstancedMesh,只保留需要的数量。或者更狠一点,自己维护一个动态数组,每帧重新上传矩阵。


第五个坑:BufferAttribute,你 dispose 了吗?

有时候我们手动创建几何体:

const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([...]);
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));

当你 geometry.dispose() 时,这些 BufferAttribute 也会被 dispose 吗?答案是:会,但前提是这些 attribute 没有被其他地方引用

如果你把同一个 BufferAttribute 赋值给两个几何体,dispose 其中一个,另一个的 attribute 还在,但底层 GPU 缓冲区可能已经被释放了,导致另一个几何体渲染出错。

所以,共用 attribute 要小心,要么就别共用,要么就用引用计数自己管理。


第六个坑:Texture 的 image,你还得手动 revoke?

如果你用 URL.createObjectURL() 加载图片,比如从本地文件上传生成纹理:

const url = URL.createObjectURL(file);
const texture = loader.load(url);
// 用完 texture 后,dispose 了纹理,但 URL 还没释放

URL.revokeObjectURL(url) 得自己调用,否则内存泄漏。而且这个泄漏不在 GPU,而在 JS 堆里,Chrome 任务管理器看不出来,但用久了页面一样卡。


第七个坑:动画混合器,你 stop 了吗?

如果你用了 AnimationMixer 播放动画,直接移除模型而不停止动画,mixer 内部还有对模型的引用,导致模型无法被垃圾回收。

// 错误
scene.remove(model);
// mixer 还在引用 model 内部的骨骼、材质等

// 正确
mixer.stopAllAction();
mixer.uncacheRoot(model); // 重要!
scene.remove(model);

这个坑我踩过,找了半天才发现 mixer 偷偷摸摸抱着模型不放手。


第八个坑:画布弹窗,我把每帧都变成了内存地雷

这事儿说起来有点丢人,但为了大伙儿不重蹈覆辙,我还是交代了吧。

去年做第一个正式项目,有个需求:点击设备,弹出一个悬浮面板,显示实时数据。当时我年轻气盛,心想这弹窗得跟3D场景“天衣无缝”啊,用普通的HTML div多掉价,飘在画布上面,一点儿都不酷。

于是我想了个自认为很牛的办法:用 Sprite + Konva

Konva 是个Canvas 2D库,可以在上面画各种UI元素。我把它画好的Canvas转成Three.js的 CanvasTexture,然后贴到 Sprite 材质上,再把 Sprite 放到3D空间里。完美!弹窗像模型一样存在于场景中,可以旋转、缩放,跟设备严丝合缝。

更让我得意的是,数据是实时更新的,比如温度、压力每秒都在变。我就写了个定时器,每秒重新画一次Konva画布,生成新的CanvasTexture,赋值给Sprite材质。

// 伪代码:每秒更新弹窗纹理
setInterval(() => {
  // 1. 清空Konva画布,重新画UI
  konvaLayer.clear();
  konvaLayer.draw();
  
  // 2. 把画布转成Three纹理
  const canvas = konvaLayer.toCanvas();
  const texture = new THREE.CanvasTexture(canvas);
  
  // 3. 赋给Sprite
  sprite.material.map = texture;
  sprite.material.needsUpdate = true;
}, 1000);

刚开始测试,一切正常,数据跳动,弹窗灵动,我美滋滋地交付了。

然后噩梦开始了。

上线第一天,现场反馈:系统运行四个小时左右就崩溃了。我远程一看,页面白屏,Chrome报错“Out of Memory”。打开任务管理器,GPU内存已经顶到2GB(我的笔记本才4GB)。

我第一反应:是不是Konva画布太大?压缩图片,降低分辨率,从512x512降到256x256。重新上线,六小时崩溃

我又想:是不是Canvas转纹理的时候没释放旧的?于是我加了一行:

if (sprite.material.map) sprite.material.map.dispose();

再上线,八小时崩溃

我开始怀疑人生了。不断优化,不断测试,内存泄漏的时间从四小时延长到十二小时、十八小时,但始终无法根除。最后,我把Konva换成原生Canvas画图,自己管理画布,甚至手动调用 canvas.width = canvas.width 来清空,二十小时崩溃一次

我盯着那个“二十小时崩溃”的数据,突然明白了一个道理:这条路,走不通

问题到底出在哪儿?

后来用Chrome Memory面板拍快照对比,发现罪魁祸首有三个:

  1. 每秒钟新建一个CanvasTexture,旧的虽然调了 dispose,但底层的 Canvas 对象还在内存里,因为Konva的 toCanvas() 每次都会生成新的Canvas,这些Canvas被 CanvasTexture 引用着,无法释放。
  2. Konva内部也有缓存,每次 draw 都会产生新的离屏Canvas,虽然我调用了 clear,但Konva为了性能,会保留一些内部对象,这些对象里又引用了画布。
  3. Sprite材质每次重新赋值 map,旧纹理即使dispose了,也可能会被GPU管线延迟释放,积累多了就爆了。

折腾了两周,我最终做了一个耻辱的决定:放弃Sprite方案,改用普通HTML div

就是那种最简单、最没技术含量的 position: absolute,通过CSS把div定位到画布上方,监听mousemoveintersect来更新位置。什么“天衣无缝”?去他的吧,不崩溃才是王道。

说来也怪,换了div之后,再也没崩过。内存稳如老狗,帧率也回来了。产品经理问:“为啥弹窗变成2D的了?”我面不改色:“这是最新设计风格,扁平化,通透。”

从那以后,我明白了一个道理:有时候“最优解”是幻觉,能稳定运行的方案才是真·最优解

如果你也遇到类似的需求,听我一句劝:别在Sprite里玩动态画布,老老实实用HTML overlay。3D就该干3D的事,2D就该干2D的事,强行融合,只会让你半夜爬起来查内存泄漏。


教训:CanvasTexture 配合实时更新的画布,每帧都要注意释放旧的纹理,并且确保画布本身没有被意外引用。但如果可能,直接用HTML元素覆盖更简单可靠。

怎么查内存泄漏?

上面说了这么多,怎么发现自己项目里有泄漏?我总结了三个方法:

1. Chrome 任务管理器

Shift + Esc 打开,找到你的页面,看两列:

  • 内存占用空间:JS 堆内存,如果持续增长,可能是 JS 对象没释放。
  • GPU 内存:显存占用,如果持续增长,肯定是 Three.js 资源没 dispose。

2. 内存快照

Chrome DevTools -> Memory 面板,拍快照,对比两次之间的差异。可以过滤 Three. 关键词,看看哪些对象没被回收。

3. 写个简单的监控

在动画循环里定期打印 renderer.info.memory

setInterval(() => {
  console.log(renderer.info.memory);
}, 5000);

geometriestextures 的数量如果只增不减,那就是泄漏了。


最后的忠告:写个 ResourceTracker

被坑多了之后,我学聪明了:写一个统一的资源追踪器,所有几何体、材质、纹理、RenderTarget 都交给它管理。

class ResourceTracker {
  constructor() {
    this.resources = new Set();
  }

  track(resource) {
    if (resource.dispose) {
      this.resources.add(resource);
    }
    return resource;
  }

  disposeAll() {
    this.resources.forEach(resource => {
      if (resource.dispose) {
        resource.dispose();
      }
    });
    this.resources.clear();
  }
}

// 使用
const tracker = new ResourceTracker();
const geometry = tracker.track(new THREE.BoxGeometry());
const material = tracker.track(new THREE.MeshStandardMaterial());
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

// 销毁时
tracker.disposeAll();
scene.remove(mesh);

这样就不会漏掉任何资源了。


写在最后

那个让我崩溃一下午的 Bug,最后发现是 RenderTarget 忘了 dispose。一行代码的事,让我查了三个小时。

从那以后,我养成了一个习惯:每次写完一个功能,就打开 Chrome 任务管理器,盯着 GPU 内存看十秒。要是数字往上涨,就一个个排查,直到它稳定为止。

内存管理这玩意儿,不出事的时候你觉得它屁用没有,一出事它就让你怀疑人生。

所以,如果你也在写 Three.js,记住这句话:

你以为释放了的资源,99% 都还在那儿装死。


互动

你遇到过最隐蔽的内存泄漏是啥?评论区晒出来,让大伙一起避坑 😏

下篇预告:【Three.js 多相机渲染】如何在同一场景里实现“画中画”效果

【ThreeJS调试技巧】那些让 Bug 无所遁形的“脏套路”

前言

有些 Bug 不报错、不崩溃,就静静在那儿恶心你

做 Web 3D 开发最头疼的是什么?

不是编译报错,不是性能瓶颈,而是那种画面看起来不对劲,但代码没任何错误的视觉 Bug。

模型缺了一半、纹理糊成一团、颜色阴阳脸、设备半透明像鬼影……这些 Bug 不会让控制台飘红,不会让页面崩溃,就静静在那儿恶心你

更气人的是,很多时候你盯着看半天,死活找不到原因。改几行代码试试?Bug 还在。回退版本?Bug 还在。重装依赖?Bug 还在。

到最后你甚至开始怀疑:是不是显卡坏了?

今天就来聊聊,我这两年攒下来的调试视觉 Bug 的脏套路。不求优雅,只求让 Bug 现原形。


场景一:模型“缺胳膊少腿” —— 面去哪儿了?

症状

模型加载完,转一圈看,有些面没了。机械臂少个夹爪、设备缺个盖子,像被切掉了一样。

排查思路

第一反应:模型导错了?用建模软件打开原文件,好好的。

第二反应:代码里隐藏了?搜 visible = false,没有。

脏套路一:强制双面渲染

// 调试代码:暴力解决
scene.traverse((child) => {
  if (child.isMesh) {
    child.material.side = THREE.DoubleSide; // 两面都给我渲染
  }
});

消失的面出现了!

这说明什么问题?面法线反了。正常应该是正面朝外,但模型里有些面是正面朝里。Three.js 默认只渲染正面(FrontSide),朝里的面直接忽略。

根治方案

// 不能留 DoubleSide,性能扛不住
// 正确做法:加载时修正法线方向
loader.load('model.glb', (gltf) => {
  gltf.scene.traverse((child) => {
    if (child.isMesh) {
      child.geometry.computeVertexNormals(); // 重新计算法线方向
    }
  });
});

注意:有些模型是艺术家手调的法线,computeVertexNormals 可能会破坏原有效果。如果修正后出现硬边或光影异常,还是得回源头改模型。


场景二:设备“鬼影重重” —— 半透明叠加

症状

某个设备变得半透明,后面的物体清晰可见,像开了透视挂。而且一旦变透明,就再也恢复不了正常。

排查思路

检查代码,发现之前加了一个“高亮选中设备”的功能:

function highlightDevice(device) {
  device.material.transparent = true;
  device.material.opacity = 0.8;
  device.material.emissive.setHex(0xffaa00);
}

高亮完恢复了吗?恢复了。但问题出在:transparent 一旦设为 true,即使改回 opacity = 1,混合模式还开着

脏套路二:重置材质

// 暴力恢复:新建材质
function resetMaterial(mesh) {
  const oldMat = mesh.material;
  mesh.material = new THREE.MeshStandardMaterial({
    map: oldMat.map,
    color: oldMat.color,
    // ... 复制其他属性
  });
  oldMat.dispose(); // 记得释放
}

设备恢复正常。

根本原因:transparent状态,不是操作。你打开它,GPU 就切换渲染管线;关掉 opacity 没用,得彻底关掉 transparent


场景三:物体“一闪一闪” —— 深度冲突

症状

两个物体挨在一起,交接处出现闪烁的白线画面抖动,转视角时尤其明显。

排查思路

第一反应:性能问题?帧率稳定 60。

第二反应:光照问题?动画循环里有光源变化?没有。

第三反应:检查两个物体的位置。

脏套路三:微调位置

// 检查两个物体的位置关系
console.log(objectA.position.z, objectB.position.z);
// 输出:0, 0

// 果然,两个面完全重合
// 微调其中一个的高度
objectB.position.z += 0.01;

不闪了。

原理:两个面完全重合,GPU 不知道谁前谁后,每帧随机决定谁在上面,看起来就是闪烁。稍微错开一点,深度测试就老实了。

进阶方案

如果必须完全重合(比如地面上铺的网格和地面本身):

// 调整渲染顺序
ground.renderOrder = 0;
grid.renderOrder = 1;

// 或者关闭一个的深度写入
grid.material.depthWrite = false;

场景四:纹理“糊成一团” —— mipmap 策略不当

症状

纹理明明分辨率很高,但在屏幕上就是模糊的色块,细节全无。

排查思路

纹理分辨率 2048x2048,不小。各向异性过滤开了 16,最大了。mipmap 也正常。问题在哪儿?

脏套路四:强制用最近过滤

// 调试代码:临时替换过滤方式
texture.magFilter = THREE.NearestFilter; // 最近点采样,清晰但锯齿严重

纹理瞬间清晰了!但锯齿出来了。

问题找到了:纹理在屏幕上的实际显示尺寸,比原始纹理小太多。GPU 用 mipmap 选了低精度层,导致模糊。

根治方案

// 方案一:限制最小 mipmap 层级
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.anisotropy = 16; // 已经开了

// 方案二:如果摄像机永远不会靠近,关掉 mipmap
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;

方案二适合固定视角的监控画面,方案一适合需要远近切换的场景。


场景五:颜色“阴阳脸” —— 法线方向混乱

症状

同样的模型、同样的材质、同样的光照,左右两边颜色不一样。一边偏暖,一边偏冷,像阴阳脸。

排查思路

光照一样,材质一样,位置对称。邪门了。

脏套路五:检查法线

// 临时显示法线方向
scene.overrideMaterial = new THREE.MeshNormalMaterial();

画面变成五彩斑斓的“法线可视化”。左右对比,发现问题:左边模型的法线方向乱了,有些面朝左,有些面朝右,光照计算自然就歪了。

根治

重新导入模型,检查建模软件里的法线方向。如果是复制过程中产生的错误,可以:

// 尝试重新计算法线
mesh.geometry.computeVertexNormals();

但同样要注意:如果模型是手调法线,重新计算可能会破坏原有光影效果。


场景六:文字“模糊不清” —— CanvasTexture 更新失败

症状

用 Canvas 生成动态纹理(比如仪表盘数字),第一次显示正常,后面数字变了,但纹理还是旧的。

排查思路

检查代码,Canvas 确实重绘了,纹理也调用了 needsUpdate。为什么没变?

脏套路六:强制清空再更新

// 错误示范
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillText(newValue, 100, 100);
texture.needsUpdate = true; // 有时候不灵

// 脏套路:重新设置整个 canvas
function updateCanvasTexture(texture, newValue) {
  const canvas = texture.image;
  const ctx = canvas.getContext('2d');
  
  // 1. 清空
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  
  // 2. 画新内容
  ctx.fillStyle = '#ffffff';
  ctx.font = 'bold 48px Arial';
  ctx.fillText(newValue, 100, 100);
  
  // 3. 脏套路:重新设置 image 并强制更新
  texture.image = canvas; // 重新赋值触发更新
  texture.needsUpdate = true;
  
  // 4. 如果还不行,重建纹理
  // const newTexture = new THREE.CanvasTexture(canvas);
  // mesh.material.map = newTexture;
  // oldTexture.dispose();
}

原理:CanvasTexture 底层缓存机制有时候会“偷懒”,重新赋值 image 能强制它刷新。


场景七:模型“位置错乱” —— 矩阵没更新

症状

InstancedMesh 或者手动修改了 matrix,但模型位置没变,或者位置乱飞。

排查思路

检查代码,确实调用了 setMatrixAt,也调用了 instanceMatrix.needsUpdate = true。为什么不动?

脏套路七:检查矩阵标志位

// 常见错误
instancedMesh.setMatrixAt(index, matrix);
// 忘了设置 needsUpdate

// 正确写法
instancedMesh.setMatrixAt(index, matrix);
instancedMesh.instanceMatrix.needsUpdate = true;

// 脏套路:如果还不动,试试强制重新上传
instancedMesh.instanceMatrix.array.set(matrix.elements, index * 16);
instancedMesh.instanceMatrix.needsUpdate = true;
instancedMesh.computeBoundingSphere(); // 有时候需要这个

进阶排查

// 打印矩阵看是否真的写进去了
const testMatrix = new THREE.Matrix4();
instancedMesh.getMatrixAt(0, testMatrix);
console.log(testMatrix); // 跟预期的一样吗?

// 检查 usage
console.log(instancedMesh.instanceMatrix.usage); // 应该是 DynamicDrawUsage 或 StaticDrawUsage

调试工具包:三板斧让 Bug 现形

遇到视觉 Bug,别急着改代码,先用这三板斧让问题显形

1. 材质覆盖大法

// 强制所有模型用一种材质,排除材质差异
scene.overrideMaterial = new THREE.MeshBasicMaterial({ color: 0xff00ff });

// 查看法线方向
scene.overrideMaterial = new THREE.MeshNormalMaterial();

// 查看 UV 分布
scene.overrideMaterial = new THREE.MeshBasicMaterial({
  map: new THREE.CanvasTexture(uvGridCanvas) // 自定义 UV 网格
});

// 查看深度
scene.overrideMaterial = new THREE.MeshDepthMaterial();

2. 线框透视

// 显示线框,看结构是否完整
scene.traverse((child) => {
  if (child.isMesh) {
    child.material.wireframe = true;
  }
});

3. 包围盒可视化

// 查看模型的包围盒是否准确
import { BoxHelper } from 'three/examples/jsm/helpers/BoxHelper.js';

const boxHelper = new BoxHelper(scene, 0xff0000);
scene.add(boxHelper);

// 查看摄像机视锥
import { CameraHelper } from 'three';
const cameraHelper = new CameraHelper(camera);
scene.add(cameraHelper);

4. 光源可视化

// 查看光源位置和方向
import { PointLightHelper, DirectionalLightHelper, SpotLightHelper } from 'three';

scene.traverse((child) => {
  if (child.isLight) {
    let helper;
    if (child.isPointLight) helper = new PointLightHelper(child, 1);
    if (child.isDirectionalLight) helper = new DirectionalLightHelper(child, 1);
    if (child.isSpotLight) helper = new SpotLightHelper(child);
    if (helper) scene.add(helper);
  }
});

调试心法:让 Bug 现形的五个问题

遇到 Bug 时,按顺序问自己这五个问题,90% 的情况都能找到线索:

1. 是所有的模型都这样,还是只有特定的?

  • 全都这样 → 可能是全局设置(光照、渲染器、后期)
  • 只有某个 → 查那个模型的材质、几何体、矩阵

2. 是固定出现,还是转视角才出现?

  • 固定出现 → 纹理、材质、模型本身的问题
  • 转视角出现 → 法线、视锥裁剪、深度测试的问题

3. 是加载完就这样,还是运行后才出现?

  • 加载完就这样 → 模型导出、加载解析的问题
  • 运行后才出现 → 动画、交互、内存泄漏的问题

4. 是只有这个设备这样,还是所有设备?

  • 只有某台电脑 → 显卡驱动、浏览器版本、硬件兼容性
  • 所有设备都这样 → 代码逻辑问题

5. 去掉这个模型/材质/纹理,Bug 还在吗?

  • 不在了 → 问题就出在去掉的东西上
  • 还在 → 继续二分法排查

终极脏套路:二分法注释

如果实在找不到原因,上最原始的二分法

  1. 注释掉一半场景
  2. Bug 还在吗?
    • 在 → 问题在前一半
    • 不在 → 问题在后一半
  3. 继续二分,直到锁定到具体某个模型或某行代码

这方法虽然笨,但绝对有效。有时候 Bug 找不到,是因为你太相信自己的直觉,而不是相信数据。


总结

视觉 Bug 调试的核心思路就一条:剥离所有“美化”,看最原始的数据

  • 颜色不对?用法线材质看方向
  • 位置不对?用线框看结构
  • 纹理糊了?用 NearestFilter 看原始分辨率
  • 闪烁?检查深度冲突
  • 半透明?重置材质

把这些“脏套路”走一遍,90% 的 Bug 都能现原形。

剩下的 10% 怎么办?那就得动真正的“脏套路”了 —— 比如把同事叫过来一起盯着看,两个人一起怀疑人生,Bug 往往就自己跑了。。。😆


互动

你在项目里遇到过什么“诡异的视觉 Bug”?最后怎么解决的?评论区分享出来,咱们一起“驱鬼” 😏

下篇预告:【Three.js 性能分析】从 Draw Call 到显存占用,一张表看懂瓶颈在哪

【ThreeJS急诊室】一个生产事故:我把客户的工厂渲染“透明”了

前言

事情是这样的。

上周五下午,我美滋滋地喝着咖啡,心想智慧工厂项目一期终于交付了,周末能躺平打游戏。

结果快下班时,企业微信狂震 —— 客户发来一段视频:

巨大的厂房里,所有设备都变成了半透明,管线像幽灵一样飘在空中,工人都懵了:“这啥情况?闹鬼了?”

我盯着视频看了三秒,脑子嗡的一下 —— 我把混合模式(Blending)搞砸了

今天不说优化,不说加载,就聊聊那些年我们踩过的 Three.js 渲染“坑”。有些 Bug 不会报错,不会崩溃,只会让你的画面变得诡异无比,然后客户快下班时给你发鬼片....


事故一:所有模型都变“透明”了

现场还原

客户打开页面,正常加载,正常显示。然后他点了一下某个设备 —— 突然,整个厂房的设备都变成了半透明,像 X 光片一样。

更诡异的是,透明之后再也变不回来了

追凶过程

我远程过去,第一反应是:材质透明度被改了?检查代码:

// 设备点击高亮
function highlightDevice(deviceMesh) {
  deviceMesh.material.transparent = true;
  deviceMesh.material.opacity = 0.5; // 半透明高亮?
}

等等,这逻辑不对啊 —— 高亮应该是变亮或者变色,怎么会是变透明?

再往下看,发现了罪魁祸首:

// 之前为了做“呼吸效果”,全局改过材质
scene.traverse((child) => {
  if (child.isMesh) {
    child.material.transparent = true; // 😱 这里埋雷了
  }
});

问题出在哪?transparent 属性一旦设为 true,Three.js 就会启用混合模式,但混合模式的默认行为是“半透明叠加”。当我把所有材质的 transparent 都打开,又没设置正确的混合参数,GPU 就按照“所有物体都是玻璃”的逻辑去渲染,结果就是整个世界都透了。

抢救方案

// ✅ 正确做法:只有需要透明的材质才开 transparent
function highlightDevice(deviceMesh) {
  // 保存原始状态
  const originalTransparent = deviceMesh.material.transparent;
  const originalOpacity = deviceMesh.material.opacity;
  
  // 临时改成高亮色 + 正常不透明
  deviceMesh.material.emissive.setHex(0xff0000);
  
  // 如果要改透明度,一定要配套设置 blending
  // deviceMesh.material.transparent = true;
  // deviceMesh.material.opacity = 0.8;
  // deviceMesh.material.blending = THREE.NormalBlending; // 明确指定混合模式
}

教训transparent 不是开关,是模式切换。乱开的后果就是 —— 客户半夜找你驱鬼。


事故二:设备“消失”了一半

现场还原

另一个项目,模型加载完,一切正常。但摄像机一转,设备的背面全不见了,像被切掉了一样。

客户:“你们这是 2.5D 模型?省了一半面数?”

我:???

追凶过程

检查模型,没问题。检查材质,没问题。最后在代码里发现了这个:

// 为了性能优化,我加了一行“神代码”
material.side = THREE.FrontSide; // 只渲染正面

这行本身没错,问题是:这个模型有些面片是单面的,摄像机转到背面,自然就看不到了。

但为什么之前没发现?因为之前的场景里,所有模型都是“双面”的,或者摄像机从来没转到背面。

抢救方案

// ✅ 稳妥做法:不确定的情况下用双面
material.side = THREE.DoubleSide;

// 如果担心性能,可以:
// 1. 确定不会看到背面的模型,用 FrontSide
// 2. 可能看到背面的,用 DoubleSide
// 3. 或者用 below 技巧:把“看不到的面”用简单颜色渲染
material.side = THREE.BackSide; // 只渲染背面,配合正面做轮廓描边

更骚的操作:用 material.wireframe 临时看一下,到底哪些面缺失了:

// 调试模式:显示线框,一眼看出是背面没了还是面片本身缺失
material.wireframe = true;

教训side 属性不是性能优化的首选。省这点性能,换来模型“缺胳膊少腿”,划不来。


事故三:模型突然“黑化”

现场还原

这是我最懵的一次。

模型加载完,亮堂堂的,一切正常。然后我加了一个点光源,想照亮某个局部 —— 结果整个模型变黑了,像被泼了墨。

追凶过程

查了两小时,最后发现问题出在 法线(Normal) 上。

// 我加载模型后,顺手做了一件事
geometry.computeVertexNormals(); // 重新计算法线

问题是:这个模型原本的法线是“艺术家”手调的,有些面故意平滑,有些面故意硬边。我这一 computeVertexNormals,把所有法线都“标准化”了,结果光照计算全错,模型就黑了。

抢救方案

// ✅ 正确做法:除非确定模型法线坏了,否则别动!
// loader.load('model.glb', (gltf) => {
//   gltf.scene.traverse(child => {
//     if (child.isMesh) {
//       // 别动法线!
//       // child.geometry.computeVertexNormals();
//     }
//   });
// });

// 如果真要重新计算,先备份原版
// const originalPositions = geometry.attributes.position.array.slice();
// computeVertexNormals();
// 对比效果,不行就恢复

教训:模型文件里的数据,每一分都有它的道理。别手贱去“优化”你不知道的东西。


事故四:边缘出现诡异白线

现场还原

这个 Bug 特别恶心。

两个模型挨在一起,相接的地方出现了一条细细的白线,时有时无,转视角就变。截图发给客户,客户:“你们模型没拼好吧?”

追凶过程

搜了半天,最后在 StackOverflow 上找到答案:深度测试(depthTest)冲突

两个模型的面完全重合,GPU 不知道谁在前谁在后,就会产生“闪烁”或“白边”。

抢救方案

// 🚫 错误原因:两个面完全重合
// plane1 和 plane2 在同一位置,深度测试打架

// ✅ 方案一:微调位置,避免完全重合
plane2.position.z += 0.01; // 稍微错开

// ✅ 方案二:调整渲染顺序
plane2.renderOrder = 1; // 确保后渲染

// ✅ 方案三:如果必须完全重合,关闭一个的深度写入
plane2.material.depthWrite = false; // 不参与深度测试

教训:GPU 很笨,你让它同时渲染两个一模一样位置的东西,它就给你“抖”给你看。


事故五:纹理突然“糊了”

现场还原

这是我最无语的一次。

模型加载完,纹理高清细腻。运行 10 秒后,纹理突然变糊,像打了马赛克。

追凶过程

排查到最后,发现是 mipmap 的锅。

// 纹理加载
const texture = loader.load('highres.jpg');
texture.generateMipmaps = true; // 默认就是 true
texture.minFilter = THREE.LinearMipmapLinearFilter;

这配置本身没错。问题是:我的摄像机从来没离模型很近过,所以 Three.js 一直在用最小的 mipmap 层级渲染,看起来就是糊的。

抢救方案

// ✅ 方案一:强制用原始纹理
texture.minFilter = THREE.LinearFilter; // 不用 mipmap
texture.generateMipmaps = false;

// ✅ 方案二:调整各向异性过滤,让 mipmap 切换更平滑
texture.anisotropy = 16; // 显卡支持的最大值

// ✅ 方案三:如果是 CanvasTexture,记得设置
texture.needsUpdate = true; // 通知 GPU 纹理变了

教训:mipmap 不是万能的。如果你的模型距离固定,或者不需要远近切换,关掉 mipmap 反而更清晰。


急诊总结

这次“急诊”遇到的 5 个病例,每一个都是真实生产事故:

症状 病因 处方
模型全透明 乱开 transparent 只有透明材质才开,明确 blending
模型缺一半 side 设错 不确定就用 DoubleSide
模型变黑 乱算法线 别动模型原始法线
白线闪烁 深度测试冲突 微调位置或 renderOrder
纹理变糊 mipmap 策略不当 根据场景选择 filter

最后说两句

Three.js 的 API 看起来简单,但每个属性背后都是 GPU 的复杂逻辑。有时候你以为开了一个“开关”,实际上改了整个渲染流水线。

这些 Bug 的共同点是:代码没报错,画面出错了。调试起来最痛苦,因为不知道从哪查起。

所以,如果你的项目也出现了诡异画面,先别急着骂显卡,看看是不是动了这几个属性:

  • transparent
  • side
  • computeVertexNormals
  • depthWrite / depthTest
  • minFilter / magFilter

互动

你的 Three.js 项目遇到过什么“灵异事件”?欢迎在评论区分享,让大伙乐乐(顺便避坑) 😏

下篇预告:【ThreeJS调试技巧】那些让 Bug 无所遁形的“脏套路”

❌