先讲个我昨天刚处理完的问题:一张圆角半透明弹窗,盖在一张深色背景图上,结果弹窗边缘多出一圈又黑又灰的“描边”,怎么调透明度都救不回来。再换个场景,一个带半透明遮罩的引导层,UI 同学说“我要的是透亮,为什么一跑起来像蒙了层灰纱”。这种问题我在社区里见过不下几十次,几乎每个做 UI 的人都会在某一天撞上,然后开始调混合模式、换图片格式、加 shader、改色值……一顿操作,碰巧能修好,但下次换个平台又翻车。
这篇就是想把“黑边”和“发灰”这两件事从玄学里拎出来。它不是什么 GPU 抽风,也不是设计稿导出的玄学问题,而是几套非常确定的数学规则在起作用——Alpha 混合公式、位图采样插值、颜色空间。理解了这三件事,你不仅自己能修,还能在评审的时候把原理讲清楚,让美术、前端、客户端同事不再靠感觉互相踢皮球。适合被半透明 UI 坑过的客户端开发、前端开发、Unity 开发者,以及想搞清楚“为什么导出后不一样”的 UI 设计师。
1. 先看现象:黑边和发灰为什么能坑到一代又一代 UI
1.1 两种典型现场
先说黑边。最常见的舞台是圆角卡片、弹窗描边、气泡、Tooltip 这一类:面板本身是半透明或者带一点毛玻璃效果,背后是一张对比度比较高的背景图,这时面板边缘会出现一条细长的深色线,尤其是圆角弧度大的位置最明显。把面板挪到白底下,这条线几乎看不见;挪到黑底下,它像描了黑边一样扎眼。
另一种黑边出在“把半透明内容渲染到 RenderTexture 再贴回 UI”的场景。比如你想给 UI 做一个实时模糊背景,先把 Camera 画面渲到 RT,再把 RT 用 RawImage 叠进界面。此时你会发现,RT 里物体和透明背景交接的边缘会发黑,整个 RT 的边缘还会有一圈很脏的暗边。很多人第一反应是改 RawImage 的透明度,结果越改越糟。
再说发灰。这个现象常出现在遮罩层、引导蒙层、半透明浮层上。设计师给的稿子是“黑色 30% 透明度”,在你机器上跑出来,却感觉像盖了一层均匀的灰布,暗部细节全部糊掉。还有一种场景:两个半透明层叠在一起,比如底部先有一个半透明白色背景卡片,上面再叠一个小半透明标签,标签已经写了 #FFFFFF 加 60% 透明度,但视觉上却呈现一种说不清道不明的脏灰色,既不够白也不算灰。
1.2 为什么说这是个“玄学重灾区”
这个问题的玄学之处在于:你很难靠“感受”找出规律。同样一张图,在 A 引擎里正常,放到 B 引擎里边缘黑了;同一套设计稿,Web 上跑出来和 Unity 里跑出来颜色不一样;甚至同一个引擎,从 Gamma 色彩空间切到 Linear 色彩空间,所有半透明遮罩的颜色都变了。
于是大家开始发明各种偏方:把透明底图往内缩一像素、给图片加描边反向遮丑、在边缘叠一个渐变遮罩、用纯黑或者纯白背景重新导出、调 Blend Factor 从 SrcAlpha 换成 OneMinusSrcAlpha……这些做法有时候确实能“表面修复”,但因为没碰到根因,换个资源、换台设备、换套 UI 层级又会复发。
其实“黑边”和“发灰”背后就是两类非常直观的数学问题:一类是颜色在透明像素边缘被错误地插值了,另一类是颜色在错误的空间里做了加法和乘法的平均。理解这两句话,就破案了一大半。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 根因第一课:Alpha 混合公式里藏着的“半透明错觉”
2.1 混合到底在算什么
不管你在哪个平台做 UI,只要涉及半透明,最终渲染管线里都在算下面这个公式(普通非预乘 Alpha):
code复制// 普通 Alpha Blend(非预乘)
outColor.rgb = src.rgb * src.a + dst.rgb * (1 - src.a)
outColor.a = src.a + dst.a * (1 - src.a)
src 是你正在绘制的半透明前景,dst 是画面中已经存在的背景。这个公式的含义很直白:前景颜色按自己的透明度权重参与,背景颜色按“剩下的透明度”权重参与。
举一个最简单的例子:一张完美的半透明白色卡片,src.rgb = (1, 1, 1),src.a = 0.5,盖在纯黑背景 dst = (0, 0, 0) 上,理论上你希望得到的视觉是一个“亮度 50% 的灰色”。可在现实的 UI 渲染里,这个“亮度 50%”本身就有歧义——你是让数值变成 0.5,还是让物理亮度变成 0.5?这两个结果完全不是一回事,后面我会在颜色空间那一章展开。
再往深处看,这个公式还默认了一件事:src 的 RGB 颜色是“未乘过 Alpha 的原始颜色”。也就是图片里一个像素如果写成 rgba(255, 0, 0, 0.5),它代表“一个半透明的纯红”。但问题是,很多图片存储
