先看问题
场景里有网格地面(Grid)、半透明的 Box 模型(默认透明度 20%,hover 时 50%)。
表现是这样的:相机转到某个角度 hover 到 Box 上时,Box 内部的拓扑线和 Box 后面的网格全部变成黑色,就好像被 Box 吃掉了一样。但换个角度又正常了。
录屏不方便放,大概长这样:
scss
正常角度 hover: 换个角度 hover:
┌──────────────────┐ ┌──────────────────┐
│ 🟨 Box (hover) │ │ 🟨 Box (hover) │
│ 能看到里面的线 │ │ ████ 黑色区域 ██ │
│ 能看到后面的网格 │ │ ████ 全黑了 ███ │
└──────────────────┘ └──────────────────┘
排查了一圈,最后发现是个一行代码就能修的问题。但这一行背后,是 3D 渲染里一个非常经典的坑。
前置知识:WebGL 渲染管线的"守门员"
在聊 Bug 之前,需要先理解 GPU 渲染管线中的两个关键概念。
深度缓冲(Depth Buffer / Z-Buffer)
想象你在画油画。你有一张画布和一个特殊的"深度记录表",表上每个格子记录着这个像素上已经画过的颜料离你的距离。
ini
画布像素 [0,0]: 已画颜料距离 0.5m → 深度值 0.5
画布像素 [0,1]: 已画颜料距离 1.2m → 深度值 1.2
画布像素 [0,2]: 还没画过 → 深度值 ∞(最远)
每次你要在一个像素上画新颜料时,GPU 做两件事:
ini
┌─────────────────────────┐
│ 新颜料离相机有多远? │
└───────────┬─────────────┘
│
┌───────────▼─────────────┐
│ 深度测试 (Depth Test) │
│ 新距离 vs 已记录的深度 │
│ ≤ 记录值 → 通过 ✅ │
│ > 记录值 → 丢弃 ❌ │
└───────────┬─────────────┘
│ 通过
┌───────────▼─────────────┐
│ 深度写入 (Depth Write) │
│ 用新距离更新深度记录表 │
│ depthWrite=true → 写 │
│ depthWrite=false → 不写│
└───────────┬─────────────┘
│
┌───────────▼─────────────┐
│ 写入颜色缓冲(屏幕上色) │
└─────────────────────────┘
对不透明物体来说这套机制很完美------前面的自然挡住后面的。
Three.js 的透明物体处理策略
Three.js 渲染一帧的顺序是:
markdown
1. 先渲染所有不透明物体(可以乱序,因为有深度缓冲兜底)
2. 再渲染所有透明物体(必须从远到近排序,保证 Alpha 混合正确)
3. 同 renderOrder 的透明物体,Three.js 按中心点到相机的距离排序
这里有个关键细节:Three.js 按物体包围盒中心点排序,不是逐像素排序。这就是 Bug 的伏笔。
Bug 真相:一行代码引发的血案
出问题的代码在 WireframeBoxView.ts 和 BoxView.ts 里:
typescript
// ❌ 出 Bug 的代码
material.depthWrite = opacity > 0.01
// hover 时 opacity = 0.25 → depthWrite = true
// 默认时 opacity = 0.2 → depthWrite = true
看起来挺合理?写代码的人还贴心地加了注释:
typescript
// 透明度为 0 时禁用深度写入,避免透明面遮挡线框
material.depthWrite = boxOpacity > 0.01
注释的意图是对的,但条件写反了。 透明度为 0(不可见)才应该不写深度,但透明度为 0.25(半透明)更不应该写深度!
为什么会黑屏?
把这个场景画出来:
ini
俯视图:
相机 (camera)
\
\ 视线方向
\
\
┌─────┴──────┐
│ Box (z=2) │ ← 盒子浮在网格上方
│ opacity=0.25
│ depthWrite=true ❌
└────────────┘
────────────── ← 网格 (z=0)
─── 拓扑线 ─── ← 拓扑 (z=0~2)
Three.js 按物体中心点排序透明物体。当相机处于某个斜角时:
rust
情况 A:排序正确(网格中心距离大 → 先渲染 → Box 后渲染)
✅ 网格先渲染 → 颜色缓冲有网格
✅ Box 后渲染 → Alpha 混合叠加在网格上 → 正常!
情况 B:排序错误(Box 中心距被判定为"更远" → 先渲染)
❌ Box 先渲染 → 深度测试通过 → depthWrite=true → 深度缓冲被 Box 面的深度值填上
❌ 网格后渲染 → 网格在 Box 后面的像素 → 深度测试失败 → 丢弃!
❌ 拓扑线后渲染 → 在 Box 内部的线条 → 深度测试失败 → 丢弃!
→ 这些像素完全没被渲染 → 显示场景背景色 → 黑色!
情况 B 就是用户看到的现象。因为 Box 的 depthWrite = true,它"霸占"了深度缓冲,所有背后的物体全被判死刑。
一张图总结:
css
深度缓冲(从左到右:屏幕上一行像素):
情况A(排序正确): 情况B(排序错误=depthWrite=true):
┌─┬─┬─┬─┬─┬─┬─┐ ┌─┬─┬─┬─┬─┬─┬─┐
│格│格│格│格│格│格│格│ │格│格│█│█│█│格│格│
│ │ │ │ │ │ │ │ │ │ │B│B│B│ │ │ ← Box 面先写入深度
│ │ │ │ │ │ │ │ │ │ │o│o│o│ │ │ 后面网格/线渲染时
│ │ │ │ │ │ │ │ │ │ │x│x│x│ │ │ 深度测试失败 → 黑色
└─┴─┴─┴─┴─┴─┴─┘ └─┴─┴─┴─┴─┴─┴─┘
✅ 正常 ❌ Box 区域黑屏
单 Mesh 内部也有问题
即使排序正确,单 Mesh 内部 6 个面的三角形渲染顺序是不确定的。背面先于正面渲染时,背面写入的深度值同样会挡住正后方的东西。
ini
Box 自身 6 个面:
┌─────────────────┐
│ 背面(先渲染) │ → depthWrite=true → 深度缓冲被填上
│ ✅ 深度测试通过 │
├─────────────────┤
│ 正面(后渲染) │ → depthTest 通过(正面比背面更近)
│ ✅ 也能渲染 │ 但背面深度已经把后面的物体挡住了!
└─────────────────┘
↓
Box 后面的网格 → 深度测试对比的是背面的深度 → 失败 → 黑色
修复:一行原则
透明材质永远不写深度缓冲 ------
depthWrite = false
修复后的代码:
typescript
// ✅ 修复后
// WireframeBoxView.ts - 创建时
const fillMat = new THREE.MeshBasicMaterial({
transparent: true,
color: style.fillColor ?? '#ff0000',
opacity: style.fillOpacity ?? 0,
depthWrite: false // 透明面永远不写深度
})
// WireframeBoxView.ts - updateStyle 时
fillMat.depthWrite = false // 透明面永远不写深度
// BoxView.ts - updateStyle 时
material.depthWrite = boxOpacity >= 1.0 // 只有完全不透明才写
// OccupancyGridView.ts - 网格填充面
const fillMat = new THREE.MeshBasicMaterial({
vertexColors: true,
transparent: true,
opacity,
side: THREE.DoubleSide,
depthWrite: false // 透明面永远不写深度
})
一共改了 3 个文件 4 处代码。
为什么 depthWrite = false 是安全的?
你可能会担心:不写深度缓冲,那透明物体岂不是会被更远的东西挡住?
答案是:不会,因为 Three.js 已经帮你处理好了。
Three.js 的渲染顺序保证了:
markdown
1. 不透明物体先渲染 → 填满深度缓冲(这些是真正的"遮挡物")
2. 透明物体后渲染 → 只做深度测试(不写深度)→ 正确 Alpha 混合
透明物体的碎片仍然读取 深度缓冲(depthTest = true),确保它们不会画在不透明物体的前面(被遮挡的部分)。但它们不写入深度缓冲,确保后面的透明物体也能正常渲染。
css
透明物体 + depthWrite=false 的行为:
不透明墙 → 深度缓冲有值 → 深度测试通过/失败正常
透明窗A(近) → 渲染 → 深度测试通过 → 不写深度 → 混合到颜色缓冲
透明窗B(远) → 渲染 → 深度测试通过(因为A没写深度!)→ 不写深度 → 混合到颜色缓冲
↑
关键:B没有因为A而失败
延伸:透明渲染的完整实践清单
| 要点 | 说明 |
|---|---|
depthWrite: false |
透明材质的底线,否则必出遮挡 Bug |
renderOrder |
多个透明物体有明确前后关系时,手动设 renderOrder 保证顺序 |
| 排序精度 | Three.js 按包围盒中心点排序,大物体可能排序不准,考虑拆分 |
transparent: true |
必须设,Three.js 靠这个标记把物体放进透明渲染队列 |
logarithmicDepthBuffer |
场景深度跨度大时开启,提高深度缓冲精度 |
| Alpha Hash | 不想处理排序问题时用 alphaHash: true(替代 transparent),无排序问题但有颗粒感 |
如果你遇到更复杂的情况
当场景中有大量互相穿插的透明物体时(比如粒子系统、烟雾、玻璃),depthWrite = false + 排序可能还不够。可以参考这些进阶方案:
- Weighted Blended OIT(顺序无关透明度):NVIDIA 的 McGuire 和 Bavoil 在 2013 年提出,无需排序,适合粒子系统
- Depth Peeling:逐层剥离,精度高但性能开销大
- MeshPhysicalMaterial + transmission:Three.js 原生支持,适合玻璃等折射材质
总结
这个 Bug 的技术本质一句话:
深度缓冲是"半透明物体没资格当裁判"的裁判机制。半透明物体不能真正遮挡背后的东西,所以它的
depthWrite必须是false。否则一旦因为排序问题它被先渲染,就会霸占深度缓冲,后面所有物体全被判负,显示为黑色。
如果你也在写 Three.js 渲染代码,记住这条规则:
typescript
// 🔴 永远不要这样做
material.transparent = true
material.depthWrite = true // ← 这俩同时出现 = 定时炸弹
// 🟢 标准写法
material.transparent = true
material.depthWrite = false
material.renderOrder = 0 // 需要时手动调整