普通视图

发现新文章,点击刷新页面。
昨天以前首页

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

作者 叶智辽
2026年3月2日 08:59

前言

你以为一个场景只能有一个相机?太天真了

上个月,产品经理拿着一个监控大屏的设计图来找我:

“小叶,你看这个效果——主画面看整个车间,右下角有个小窗口专门盯着那台最关键的设备,实时放大,能不能做?”

我一看,这不就是“画中画”吗?电视里看球赛的时候,主画面全场,小窗口给特写镜头,一模一样。

“能做是能做……”我脑子飞速转了一圈,“但你要知道哦,这是3D场景,不是2D视频,得渲染两次。”

产品经理眨眨眼:“那又怎样?你就说能不能做吧。”

“能。”

为了这个“能”,我研究了一下午多相机渲染。今天就把研究成果分享出来,让大伙儿少走弯路。


一、多相机渲染是啥?

平时我们写Three.js,都是一个场景、一个相机、一个渲染器:

renderer.render(scene, camera);

这叫单视角。

但Three.js允许你在同一帧里用多个相机渲染同一个场景。每个相机可以看到不同的角度、不同的位置,然后通过设置视口(viewport),把它们渲染到屏幕的不同区域。

比如:左边一个相机看整体,右边一个相机看局部;或者主画面占满,右下角一个小窗口显示另一个视角。

这就是多相机渲染。


二、最简单的画中画实现

先来个最基础的版本:主相机看整个场景,副相机放在一个设备旁边,把画面渲染到右下角一个小方框里。

1. 创建两个相机

// 主相机:看整体
const mainCamera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
mainCamera.position.set(10, 10, 20);
mainCamera.lookAt(0, 0, 0);

// 副相机:特写某个设备
const subCamera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000); // 宽高比暂时设1,后面按视口算
subCamera.position.set(2, 2, 5); // 假设设备在原点附近
subCamera.lookAt(0, 0, 0);

2. 在渲染循环里分别设置视口

function animate() {
  requestAnimationFrame(animate);

  // 1. 渲染主相机(全屏)
  renderer.setViewport(0, 0, window.innerWidth, window.innerHeight);
  renderer.render(scene, mainCamera);

  // 2. 渲染副相机(右下角 300x200 的区域)
  const subWidth = 300;
  const subHeight = 200;
  renderer.setViewport(
    window.innerWidth - subWidth,  // x 起点
    0,                              // y 起点(从底部开始算的话,这里是0)
    subWidth,
    subHeight
  );
  renderer.render(scene, subCamera);
}

注意:setViewport 的坐标系是左下角为原点(0,0)。所以右下角的位置是 (window.innerWidth - subWidth, 0)

3. 别忘了清除深度

两个相机渲染同一个场景,如果不做处理,第二个相机的渲染可能会被第一个相机的深度信息干扰。

解决方案:每次渲染前清除深度缓冲区,但保留颜色缓冲区(或者直接重新清除全部)。

// 渲染主相机前,正常清除
renderer.clear();

// 渲染主相机
renderer.render(scene, mainCamera);

// 渲染副相机前,只清除深度(不清除颜色,否则主画面被清掉)
renderer.clearDepth();
renderer.render(scene, subCamera);

这样副相机的画面就能正确覆盖在主画面之上。


三、让副相机可交互

光有画面还不够,产品经理说:“小窗口能不能也支持旋转、缩放?我想仔细看看那台设备的细节。”

也就是说,副相机得绑定一套控制器。

1. 两套控制器?

直接用两个 OrbitControls 绑到同一个 canvas 上会有冲突。因为鼠标事件只有一个,你不知道用户是想操作主相机还是副相机。

解决方案:通过点击切换激活状态。点击哪个窗口,哪个相机就响应控制器。

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

// 主相机控制器
const mainControls = new OrbitControls(mainCamera, renderer.domElement);
mainControls.enableDamping = true;

// 副相机控制器(先禁用它)
const subControls = new OrbitControls(subCamera, renderer.domElement);
subControls.enabled = false;

// 点击事件判断
renderer.domElement.addEventListener('click', (event) => {
  const mouseX = event.clientX;
  const mouseY = event.clientY;

  // 判断是否点击在副相机区域
  const subWidth = 300;
  const subHeight = 200;
  const subLeft = window.innerWidth - subWidth;
  const subBottom = 0;
  const subRight = window.innerWidth;
  const subTop = subHeight;

  if (mouseX >= subLeft && mouseX <= subRight && mouseY >= subBottom && mouseY <= subTop) {
    // 点击在副窗口,激活副相机控制器,禁用主相机控制器
    subControls.enabled = true;
    mainControls.enabled = false;
  } else {
    // 点击在主窗口,激活主相机控制器,禁用副相机控制器
    subControls.enabled = false;
    mainControls.enabled = true;
  }
});

2. 控制器的更新

在动画循环里,同时更新两个控制器:

function animate() {
  requestAnimationFrame(animate);

  mainControls.update(); // 即使 enabled=false 也可以调用,只是没效果
  subControls.update();

  // 渲染代码同上
  // ...
}

四、进阶:小地图(俯视图)

画中画还有一个常见用法:小地图。主画面是自由视角,右下角显示一个从上往下的固定俯视图,帮助用户定位。

实现起来超级简单:

// 创建俯视相机
const mapCamera = new THREE.OrthographicCamera(-20, 20, 20, -20, 0.1, 100);
mapCamera.position.set(0, 30, 0);
mapCamera.lookAt(0, 0, 0);
mapCamera.up.set(0, 0, 1); // 让Z轴朝上,这样俯视图更符合直觉(如果场景是Z轴朝上的话)

// 在动画循环里
renderer.setViewport(0, 0, window.innerWidth, window.innerHeight);
renderer.render(scene, mainCamera);

renderer.clearDepth();
renderer.setViewport(window.innerWidth - 200, window.innerHeight - 200, 180, 180);
renderer.render(scene, mapCamera);

注意正交相机 OrthographicCamera 的参数:left/right/bottom/top 决定了视景范围,数值越小,放大倍数越大。

为了让小地图更清晰,可以关闭一些后期效果,或者用简单的 MeshBasicMaterial 渲染一个副本,但为了简单,直接复用原场景也行。


五、坑点汇总

1. 宽高比

副相机的宽高比要跟视口的宽高比保持一致,否则画面会拉伸:

subCamera.aspect = subWidth / subHeight;
subCamera.updateProjectionMatrix();

每次窗口大小变化时,也要重新计算。

2. 深度冲突

如果不调用 clearDepth(),第二个相机的渲染可能会因为深度测试失败而显示不全。上面已经给出解法。

3. 控制器冲突

上面用了点击切换的方法,但用户可能想同时操作?不太现实,因为鼠标只有一个。如果非要同时操作,可以考虑把副相机绑定到键盘控制,或者用不同的鼠标按钮。

4. 性能

多渲染一次,性能开销肯定翻倍。如果副相机视口很小,可以降低它的渲染分辨率或关闭阴影、后期等:

// 在渲染副相机前,临时关掉阴影
renderer.shadowMap.enabled = false;
renderer.render(scene, subCamera);
renderer.shadowMap.enabled = true; // 恢复

或者更狠一点:副相机用更低精度的几何体(LOD),但实现起来复杂,这里不展开。

5. 清理

副相机用完记得 dispose,尤其是如果动态创建和销毁:

subCamera = null;
// 如果有控制器,也调用 dispose
subControls.dispose();

六、实战:设备特写画中画

最后分享一下我在那个监控项目里的实际代码片段:

// 初始化
const mainCamera = new THREE.PerspectiveCamera(45, width/height, 0.1, 1000);
const subCamera = new THREE.PerspectiveCamera(60, 1, 0.1, 1000);
subCamera.position.copy(device.position.clone().add(new THREE.Vector3(1, 1, 2)));
subCamera.lookAt(device.position);

// 动画循环
function render() {
  requestAnimationFrame(render);

  // 更新主控制器
  mainControls.update();

  // 主渲染
  renderer.setViewport(0, 0, width, height);
  renderer.clear();
  renderer.render(scene, mainCamera);

  // 副渲染(右下角 300x200)
  renderer.clearDepth();
  renderer.setViewport(width - 310, 10, 300, 200);
  renderer.render(scene, subCamera);

  // 画一个边框,突出小窗口
  // 可以用CSS,或者用另一个Sprite/Canvas画上去,这里省略
}

产品经理看了很满意,说:“就是这个效果!”

我心想:为了这个效果,我研究了半天多相机,但值了。


七、总结

多相机渲染并不复杂,核心就三步:

  1. 创建多个相机
  2. 在渲染循环里分别设置视口
  3. 处理好深度清除和宽高比

有了它,你可以实现:

  • 画中画特写
  • 小地图导航
  • 分屏对比
  • 多角度监控

下次产品经理再提类似需求,你就可以自信地说:“能,而且我能给你三个方案。”


互动

你用过Three.js的多相机渲染吗?实现了什么好玩的效果?评论区晒出来,让我抄抄作业 😏

下篇预告:【Three.js后期处理】如何让你的场景拥有电影级调色

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

作者 叶智辽
2026年2月28日 22:24

前言

你以为你 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 无所遁形的“脏套路”

作者 叶智辽
2026年2月27日 09:37

前言

有些 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 到显存占用,一张表看懂瓶颈在哪

❌
❌