返回博客

WebGL 着色器渲染正常却全黑——负 z-index 被父级背景盖住了

在给某个 UI 组件库做背景着色器效果时(丝绸流光、液态金属、极光渐变这类全铺 shader),接连遇到一件让人抓狂的事:组件在一个带 transform 的 hero 容器里表现完美,放进另一个普通卡片容器里就变成一整块纯黑矩形——不报错,canvas 尺寸也对,WebGL 上下文活着,shader compile 无异常。

第一反应是 GL 管线出了问题。结果查了半天,发现 GL 根本没问题。问题出在 CSS。

现象

  • 多个 shader 背景组件(silk、threads、iridescence、liquid-chrome)在某些容器内呈现纯黑或全透明
  • 截图工具 / drawImage 取到的像素全是 [0,0,0,0]
  • 开 DevTools 查 WebGL 状态:gl.isContextLost() === false,canvas 宽高正确,draw call 都触发了
  • 换一个有 transformisolation: isolate 的父容器——立刻正常

根因

这些 shader 组件的内层 div 用了 position: absolute; inset: 0; z-index: -10(Tailwind 的 -z-10),意图让背景层沉到兄弟内容之后。

CSS 规范里有一条反直觉的规则:负 z-index 的子元素,只能沉到其最近的"层叠上下文"的背景之上。如果父容器不是层叠上下文(z-index: auto、没有 isolation、没有 transformopacity 为 1、filter: none……),那负 z-index 子元素会穿透它,直接沉到再上一层祖先的背景之下——而如果祖先刚好有一个不透明背景色,canvas 就这样被盖住了。

坑:-z-10 遇到非层叠上下文父级会穿透到更外层

position: relative 不会自动建立层叠上下文(z-index 为 auto 时)。把 -z-10 的 canvas 放进一个有 bg-surface 但没有 isolation: isolate / transform / 非 auto z-index 的容器,canvas 就会被绘制到该容器背景色的后面,肉眼不可见。drawImage 和 canvas 截图此时给出 [0,0,0,0],很容易误判为 GL 未绘制。

诊断:用 readPixels 在同一帧内采样

drawImage → 2D canvas → getImageData 会给出假阴性(WebGL 默认 preserveDrawingBuffer: false,buffer 在帧末自动清除)。正确做法是在 RAF 循环里、gl.drawArrays / gl.drawElements 之后立即调用 gl.readPixels

// 临时插入 RAF 循环末尾,在 render 调用之后:
const g = canvas.getContext("webgl2") || canvas.getContext("webgl");
const w = g.drawingBufferWidth, h = g.drawingBufferHeight;
const band = new Uint8Array(4 * w * 6);
g.readPixels(0, (h >> 1) - 3, w, 6, g.RGBA, g.UNSIGNED_BYTE, band);
let nonZero = 0, maxA = 0;
for (let i = 0; i < band.length; i += 4) {
  if (band[i] || band[i+1] || band[i+2] || band[i+3]) nonZero++;
  if (band[i+3] > maxA) maxA = band[i+3];
}
console.log(`buf=${w}x${h} nonZero=${nonZero} maxA=${maxA}`);

如果输出 nonZero=3600 maxA=255——GL 渲染完全正确,只是被 CSS 遮住了,立刻把排查方向从「GL bug」切换到「CSS 层叠问题」。

正解

把组件内背景层容器的 z-index-10 改为 0(Tailwind: -z-10z-0):

// 修改前(脆弱)
<div className="pointer-events-none absolute inset-0 -z-10">
  <canvas ref={canvasRef} />
</div>
 
// 修改后(健壮)
<div className="pointer-events-none absolute inset-0 z-0">
  <canvas ref={canvasRef} />
</div>

z-0 保证 canvas 始终在父级背景之上,同时比 relative z-10 的兄弟内容层级低——不依赖父容器是否为层叠上下文,放进任意容器都不会静默失效。

同步更新组件测试里检查 className 的断言(toContain("-z-10")toContain("z-0"))。

正解:z-0 代替 -z-10,背景层不再穿透父级

z-0 让 canvas 停在当前层叠上下文内,不穿透任何父级,兼容所有父容器写法;z-10 的兄弟内容仍然压住它。这是背景层 shader 组件的鲁棒写法,不需要要求每个使用方都在父容器加 isolation: isolate

一句话外卖

负 z-index 背景层在非层叠上下文父容器里会静默穿透——readPixels 采样确认 GL 已绘后,立刻排查 CSS,把 -z-10 换成 z-0 一劳永逸。