透明渲染为什么这么难?
Three.js 渲染原理
从深度缓冲到排序机制,彻底搞清楚
transparent、depthTest、depthWrite三个开关的职责与取舍,不再反复踩同一个坑。Three.js · WebGL · 渲染原理 · 约 4500 字
如果你在 Three.js 里做过半透明的地图标牌、玻璃面板、粒子效果,一定遇过这个问题:明明设置了透明,但物体之间的遮挡关系就是不对。改来改去,偶尔对了,下次又乱了。
根本原因不是 API 太复杂,而是我们把几个职责完全不同的开关混在了一起。这篇文章从渲染原理讲起,帮你把它们彻底分开。
01 不透明物体为什么没有这个烦恼
要理解透明难在哪,先要理解不透明为什么简单。GPU 画每一帧时都维护着两块缓冲:
- 颜色缓冲: 记录每个像素的最终颜色
- 深度缓冲: 记录每个像素当前最近物体的深度值

不透明渲染:深度缓冲自动解决遮挡,谁近谁赢
这套机制稳定、高效,完全自动。不透明物体主要靠深度,不需要关心绘制顺序。
但半透明物体打破了这个假设。
02 半透明真正需要的是什么
半透明不是"谁近就显示谁",而是前面的颜色按 alpha 混合到后面的颜色上。这需要两件事同时成立:
正确的半透明渲染 = 现有深度信息 + 正确的绘制顺序
先画远的,再画近的,让近处的透明层叠加在远处的内容上。

半透明必须从后往前绘制,才能正确叠色
也正因为如此,半透明渲染天然比不透明渲染更脆弱,它对绘制顺序有严格依赖,而深度缓冲无法替代这件事。
03 三个开关,三件不同的事
透明配置混乱的根本原因,是把三个职责完全不同的开关当成"一套透明配置"来理解。分开看,每个开关其实都很清晰。
| 属性 | 它决定的是 | 典型值 |
|---|---|---|
transparent |
这个材质是否进入透明渲染路径,在不透明物体之后渲染,并参与 alpha blending | true / false |
depthTest |
绘制这个片元前,是否与现有深度缓冲比较,也就是会不会被别人挡住 | 通常 true |
depthWrite |
绘制完这个片元后,是否把自己的深度写入缓冲,也就是会不会挡住别人 | 不透明 true,半透明通常 false |
关键区分:
transparent: true不等于depthWrite: false。前者说"我要走透明路径",后者说"我要不要修改深度缓冲"。它们常常一起出现,但是两件完全独立的事。

三个开关各司其职,不要混为一谈
04 depthWrite: false 到底解决了什么问题
这是最多人在第一次接触透明渲染时会卡住的点。直觉上你会想:前面的东西在前面,就应该写深度,把后面的挡住。
这句话对不透明物体完全正确。对半透明物体则不成立。

depthWrite 决定透明物体能否让后面的内容参与混色
所以 depthWrite: false 不是"关掉遮挡",而是放弃用深度来挡住后面,改用绘制顺序来保证混色正确。
关掉
depthWrite后,透明卡片仍然会被实体物体挡住。 因为depthTest依然是true,它仍然会读深度缓冲,只是不写。墙后面的卡片,还是会被墙遮挡。
05 透明其实分两种,策略完全不同
很多人以为"透明"只有一种。实际上有两种完全不同的透明,混淆它们就是大多数透明 Bug 的根源。

两种透明的视觉效果和技术策略完全不同
06 Three.js 帮了你什么,没帮你什么
Three.js 默认会做一件很有帮助的事:
- 先画所有不透明对象
- 再画所有透明对象
- 透明对象按对象中心点到相机的距离,从远到近排序
对"数量不多、结构简单、互相不怎么穿插"的透明对象,这已经足够好。
但它有一个明确的边界:
Three.js 默认排序的是对象(Object3D),不是对象内部的每个三角形。
如果一个 Mesh 里批量打包了很多透明卡片,Three.js 只能把整个 Mesh 当作一个单位排序,不知道内部哪张卡片应该先画、哪张应该后画。

批量合并几何体时,Three.js 的对象级排序无法处理内部卡片的顺序
这就是为什么地图标牌类组件通常需要在批次内部自己做从远到近排序。Three.js 的自动排序在这里帮不上忙。具体怎么做,见第 08 节。
注意性能陷阱: 批次内部的距离排序发生在 CPU 上,每帧都要对所有卡片重新排序并上传 index buffer。卡片数量大时,这个成本需要纳入考量。优化方案详见第 08 节。
07 圆角半透明卡片为什么特别难
地图标牌是一个典型的混合难题,因为一张卡片通常同时有两种透明区域:

一张卡片里同时存在二值透明和连续半透明,不能靠单一策略解决
这意味着一张 billboard 同时需要两种处理策略:
ts
// 圆角外:用 alphaTest 裁掉,不参与混合
alphaTest: 0.05
// 面板本体:用半透明混合 + 关闭深度写入
transparent: true
depthTest: true
depthWrite: false
// 同时在批次内部做从远到近排序(机制详见第 08 节)
alphaTest 先把完全透明的像素裁掉,让它们不参与混合运算;剩余的半透明区域则靠正确排序来混色。两者各司其职。
08 "批次内部排序"到底是怎么做的
前几节多次提到"批次内部从远到近排序",这里把它的具体机制展开讲清楚。
当多张卡片合并成一个 Mesh,GPU 实际处理的是一堆三角形。每张卡片由 2 个三角形(4 个顶点)组成。决定这些三角形绘制顺序 的,不是顶点数据,而是 index buffer(索引缓冲)。

顶点位置不动,只重写索引顺序,就能控制三角形的绘制先后
代码层面的做法如下:
ts
// 每帧(或相机移动后)执行
const sorted = cards
.map((card, i) => ({
i,
dist: camera.position.distanceTo(card.worldPos)
}))
.sort((a, b) => b.dist - a.dist) // 远→近
const indices = new Uint32Array(cards.length * 6)
sorted.forEach(({ i }, slot) => {
const base = i * 4 // 每张卡片 4 个顶点
const out = slot * 6 // 每张卡片 6 个索引(2 个三角形)
indices[out + 0] = base + 0; indices[out + 1] = base + 1; indices[out + 2] = base + 2
indices[out + 3] = base + 2; indices[out + 4] = base + 1; indices[out + 5] = base + 3
})
geometry.index.array.set(indices)
geometry.index.needsUpdate = true // 通知 GPU 本帧重新上传
这个操作完全在 CPU 上进行,代价是 O(n log n) 排序 + 一次 index buffer 上传。对几十到几百张卡片来说完全可控,但随卡片数量增长开销是真实存在的。
三个常见优化方向:
① 只在相机发生位移或旋转时触发排序,静止帧跳过;
② 用预分配的
Float32Array复用内存,避免每帧 GC;③ 卡片数量极大时(1000+)把排序逻辑移入 Web Worker,不阻塞主线程。
09 这套方案的边界在哪里
以上策略已经能覆盖绝大多数地图标牌和 UI 面板场景。但它不是万能的。以下情况会让它失效:
- 透明几何体彼此穿插,单一顺序无法对所有像素都正确
- 大量透明层堆叠,形成循环遮挡关系
- 需要接近物理正确的复杂折射和透射效果
遇到这些情况,需要考虑更高级的 OIT(Order-Independent Transparency)方案:
| 方案 | 原理 | 适用场景 |
|---|---|---|
| Weighted Blended OIT | 按权重累积透明片元,最后合成,近似解 | 大量粒子、烟雾、透明层很多 |
| Depth Peeling | 多 pass 逐层剥离透明层再合成,精确解 | 玻璃穿插、高精度透明 |
| Alpha Hash | 用随机抖动近似透明,无排序依赖 | 表面透明度均匀、可接受轻微噪点 |
对地图标牌来说,这些高级技术通常属于"理论上可用,工程上没必要先上"的范围。
10 快速决策表
遇到透明配置问题,先判断你的内容属于哪种情况:
情况 A:主体不透明,只有外轮廓裁剪
- 图标、带镂空的标记
- 圆角外是空白
- 本身不透出背景
推荐配置:
- 用
alphaTest - 保留
depthWrite: true - 稳定遮挡,像实体卡片
情况 B:背景是真正的半透明
- 能透出后面的内容
- 玻璃质感面板
- alpha 在
0.3--0.9之间
推荐配置:
transparent: truedepthWrite: false- 再配合批次内部从远到近排序
情况 C:两者都有(典型地图标牌)
- 圆角外是
alpha=0的空白 - 面板本体是
alpha=0.75的半透明背景
推荐配置:
alphaTest: 0.05,裁掉完全透明的角落transparent: true+depthWrite: false,处理面板半透明混合- 批次内部排序保证叠加顺序正确
如果只记住一句话,请记这个:
不透明物体靠深度,半透明物体靠顺序。
一张卡片里两者都有,就需要两套策略各司其职。
一旦把 transparent、depthTest、depthWrite 的职责分清楚,再回头看任何透明配置,都不会觉得矛盾了。