透明护盾为什么不能只调低 Alpha:混合、深度写入与排序合同
把护盾颜色的 Alpha 改成 0.34,画面并不会自动成为“正确的半透明”。同样两层蓝绿球壳,颜色输出与混合因子不匹配时明显发白;透明 Pass 写入深度时,远处球壳的一部分直接消失;只把绘制顺序倒过来,重叠区的颜色也会变化。片元公式仍然输出了合法 RGBA,错误发生在 Shader 与宿主共同组成的渲染状态里。

前文已经建立两条边界:第四篇的 Fresnel 必须由同一空间的法线与观察方向计算,第六篇的 Pass 顺序和深度策略由宿主提交。透明护盾复用这两点,但状态不能再拆开讨论。一次 Draw 必须同时声明颜色是直通还是预乘、使用哪组混合因子、是否深度测试、是否写深度,以及它在透明队列中的位置。
Alpha 只描述覆盖率,不能替混合公式作决定
本章继续用球面近似法线计算边缘项,正视区域透明,轮廓逐渐增强。片元阶段选择预乘输出:RGB 在写出前乘以当前 Alpha,宿主对应使用 ONE, ONE_MINUS_SRC_ALPHA。
// .tmp/game-shader-webgl-lab/chapters/07-transparent-shield/shield.frag
#version 300 es
precision highp float;
in vec2 vLocal;
uniform vec3 uColor;
uniform float uAlpha;
uniform bool uPremultiplyOutput;
out vec4 outColor;
void main() {
float radiusSquared = dot(vLocal, vLocal);
if (radiusSquared > 1.0) discard;
float normalZ = sqrt(max(0.0, 1.0 - radiusSquared));
float fresnel = pow(1.0 - normalZ, 3.0);
float alpha = clamp(uAlpha + fresnel * 0.42, 0.0, 0.82);
vec3 linearColor = uColor * (0.32 + fresnel * 1.25);
outColor = vec4(
uPremultiplyOutput ? linearColor * alpha : linearColor,
alpha
);
}
若保留直通 RGB,却仍让宿主使用预乘混合,源颜色不会被覆盖率衰减。固定画面中,正确合同的 RGB 区域亮度和为 11,450,759,错配后升至 19,770,605。这个数字只用于同一帧缓冲上的行为回归,不是光度测量,更不是性能数据。
预乘表示还有一个容易被忽略的插值边界。透明纹理的完全透明纹素若保留高饱和 RGB,双线性过滤会先把这些颜色混入边缘,再进入混合;预乘资源把透明区 RGB 同时压到零,过滤后的颜色与覆盖率保持一致。本章没有采样纹理,因此只验证 Shader 输出侧的表示合同;接入贴图时,资源导入器也必须声明并保持相同约定,不能只在最终片元临时修正。
也可以选择直通 Alpha,并使用 SRC_ALPHA, ONE_MINUS_SRC_ALPHA;关键不在于预乘永远更正确,而在于纹理边缘、Shader 输出和管线混合因子必须使用同一种表示。若资源已预乘,Shader 又乘一次 Alpha,边缘会反向变暗;若资源未预乘却按预乘方式混合,透明边缘就会泄出原始 RGB。
不透明深度可以保留,透明层却不能抢先封口
护盾需要被场景墙体遮挡,因此深度测试仍然开启。场景不透明物先绘制并写入深度;护盾片元若在墙后,测试失败,自然不会穿出。透明护盾自身则关闭深度写入,否则先画的透明表面会像不透明物一样挡掉后续透明层。
// .tmp/game-shader-webgl-lab/chapters/07-transparent-shield/app.js
function drawCase(item) {
const state = normalizeShieldState({
alpha: item.alpha ?? 0.34,
depthWrite: item.mode === 'depthWrite',
premultiplied: item.mode !== 'colorMismatch',
});
drawOpaqueScene();
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.depthMask(state.depthWrite);
const far = () => drawDisc(
[-0.16, 0.02], 0.62, 0.30,
[0.10, 0.56, 1.0], state.alpha, state.premultiplied
);
const near = () => drawDisc(
[0.16, 0.02], 0.58, 0.05,
[0.16, 1.0, 0.72], state.alpha, state.premultiplied
);
far();
near();
gl.depthMask(true);
gl.disable(gl.BLEND);
}
探针的错误样本故意让近护盾先画并写深度,远护盾在重叠处被深度测试拒绝:正确合同保留 11,284 个重叠像素,错误状态只剩 8,112 个。关闭透明深度写入并不等于关闭深度测试;前者允许透明层继续参与混合,后者会让墙后的护盾也覆盖到前景。

封闭球壳还会遇到前后表面重复叠加。本章的最小网格只绘制一个解析圆面,用来隔离管线状态;接入真实球体时,应明确剔除哪一面。只需要外壳轮廓时通常保留一组表面;确实要看到内外两层时,则应把它们当成可见的两次透明贡献,而不是偶然关闭剔除后接受重复变亮。
排序决定透明层的合成结果
标准 Alpha 混合不满足交换律。两个不相交透明对象应按相机深度从远到近提交;本章把顺序从远到近倒为近到远后,区域亮度和从 11,450,759 变为 11,272,094。变化不大并不代表顺序无关,只说明当前两种颜色和覆盖率相近。颜色差异、Alpha 或重叠面积增大后,误差会更明显。
排序单位也有边界。按对象中心排序无法正确处理相互穿插的球壳,单个封闭网格的三角形也未必天然满足远到近。需要大量交叉透明面时,应切分几何、限制效果形状,或采用加权混合等顺序无关透明近似;那会改变颜色累积公式和缓冲需求,不能用一次队列排序悄悄替代。
宿主还在 Draw 前把 Alpha 约束到 [0,0.72]:输入 2.0 提交为 0.72,非有限值和负数退化为零。这样玩法参数错误不会把护盾变成意外的不透明遮挡物,也不会把非有限数送入混合链。

$ python3 $WORK_DIR/.tmp/daily-labs/2026-08-11-1930/run_lab.py
exit_code=0
pass=10 fail=0 skip=0 duration=16.308s
correct_overlap_pixels=11284
depth_write_overlap_pixels=8112
correct_luminance=11450759
color_mismatch_luminance=19770605
真实 Chrome 在 WebGL2 中完成两组 GLSL ES 3.00 编译、24 次 Draw、像素回读与截图,Shader 链接日志为空。这里没有 GPU 计时,只能确认状态行为;不同透明方案的代价还要结合对象数、屏幕覆盖率、Overdraw 和目标硬件测量。
只调低 Alpha 的问题至此有了可执行边界:Shader 输出预乘颜色,宿主绑定匹配的混合因子;不透明场景先写深度,透明护盾保留深度测试但关闭写入;多个护盾按远到近提交。它适用于可稳定排序、交叉有限的球壳效果,不解决折射、透明阴影、MSAA 或大规模交叉几何。下一篇水面扭曲会继续复用宿主持有采样与 Pass 状态的边界,但把问题转向两层 UV 扰动及屏幕边缘采样。