Three.js 半透明物体渲染黑背景? depthWrite 了解一下

先看问题

场景里有网格地面(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.tsBoxView.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 // 需要时手动调整

参考

相关推荐
SmalBox9 小时前
【节点】[Arctangent节点]原理解析与实际应用
unity3d·游戏开发·图形学
SmalBox1 天前
【节点】[Arctangent2节点]原理解析与实际应用
unity3d·游戏开发·图形学
SmalBox2 天前
【节点】[Arcsine节点]原理解析与实际应用
unity3d·游戏开发·图形学
潇湘散客3 天前
CAX软件插件化设计实现牛刀小试
c++·算法·图形学·opengl
SmalBox3 天前
【节点】[Arccosine节点]原理解析与实际应用
unity3d·游戏开发·图形学
潇湘散客3 天前
CAX软件插件化设计实战:从框架到3D基础功能落地
c++·图形学·opengl
SmalBox4 天前
【节点】[Truncate节点]原理解析与实际应用
unity3d·游戏开发·图形学
SmalBox5 天前
【节点】[Step节点]原理解析与实际应用
unity3d·游戏开发·图形学
SmalBox6 天前
【节点】[Sign节点]原理解析与实际应用
unity3d·游戏开发·图形学