Procreate 花饰鸟上网记:带背景 + 混合模式 vs 透明通道
用 Procreate 画的 Ornamental Penmanship 花饰鸟 PNG 贴到多主题网页背景上,扣掉底色反而让笔锋泛灰?从一次 Hero 图片闪烁的修复讲起。
首页 Hero 那只鸟,是我在 Procreate 里用 Ornamental Penmanship(花饰英文书法)配合 flourishing 风格的鸟形装饰纹画的一幅数字作品 —— 本质上是单色笔尖在白/黑底上划过的一堆半透明笔锋。把它贴到支持多主题的网页上时撞到一个有意思的现象:带原图背景色 + mix-blend-mode 看起来干净利落,扣掉背景 做成透明 PNG 后,笔画边缘反而泛起一圈灰雾。顺藤摸下去又牵出一个更奇怪的问题 —— 只有深色版本的原图才会在加载瞬间闪一下纯色底,浅色版完全没这个毛病。
记一下两个观察背后的原理。
观察一:为什么只有 dark 版本的 PNG 会闪背景
首页用的是那只 Procreate 导出的花饰鸟:浅色主题用黑笔锋 + 白底的原图,深色主题用 PIL 反色后生成白笔锋 + 黑底的对应版本。两张图都靠 mix-blend-mode 把大色块底儿抠掉:
<Image
src={useDarkBird ? "/images/bird_dark.png" : "/images/bird.png"}
style={{ mixBlendMode: useDarkBird ? "screen" : "multiply" }}
/>理论上这套写法对称得很完美:
multiply(白, 任意色) = 任意色—— 白底 PNG 在浅色页面上被"吃掉"screen(黑, 任意色) = 任意色—— 黑底 PNG 在深色页面上被"吃掉"
数学上两者一模一样。可实际表现不是这样:浅色主题的首屏稳如泰山,深色主题每次刷新都能看见一下黑色方框闪过再消失。
问题出在 SSR 初始渲染 和 混合模式的作用域 这两个地方凑在了一起。
1. 初始渲染拿的是"默认图"
Hero 是客户端组件,要读 useTheme() 才能决定走哪一张。而 useTheme() 在服务端和 hydration 之前都返回 undefined。为了避免 hydration 不一致,组件要用 mounted gate 兜底:
const mounted = useMounted();
const useDarkBird = mounted && (active === "dark" || active === "moonlight");mounted === false(SSR + 首次渲染)→ 用浅色图mounted === true(hydration 完成之后)→ 才会切到深色图
所以深色主题的用户每次刷新,浏览器先画的都是浅色图,过几十毫秒 hydration 完才换成深色图。 浅色主题的用户则完全对得上,SSR 给啥就是啥,没有切换这一步。
2. mix-blend-mode 需要看得见背景
这还不够解释"黑色方框"。因为按理说即使混用,多出来那一帧应该是浅色图叠在深色背景上,浅色图用了 multiply,白底 × 深色背景 = 深色背景,看起来应该是一片"很暗、看不清笔画",而不是一块纯黑。
真正的坑在 Framer Motion 身上。Hero 外面套了一层 motion.div:
<motion.div initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }}>
<Image ... />
</motion.div>transform 会创建新的 合成层 (stacking context)。mix-blend-mode 只能和"同一合成层内的 backdrop"做混合 —— 也就是说,一旦 transform 生效,screen / multiply 就看不到 <body> 的真实底色了,它们只能和这个 motion 层自己的 backdrop(通常是透明)去混。
结论:切换那一瞬间,深色 PNG 的黑底没有 backdrop 可以抠,就以"纯黑 RGB"的身份原样显示出来,表现成那块黑色闪屏。浅色版本因为 SSR 拿的就是对的图,加上浅色页面的底色本身就接近白,"看错图 + 混合失效"两个缺陷被浅色背景吞掉了,眼睛看不出问题。
一句话:不是 multiply 比 screen 聪明,是浅色场景运气好。
观察二:扣掉背景之后,笔锋为什么发灰
既然 mix-blend-mode 在 transform 里不靠谱,最干净的办法是让 PNG 本身就带 alpha 通道,不再依赖混合模式。第一版我用 PIL 写了一个简单的阈值替换:
# 把接近白色的像素设成透明
if max(r, g, b) >= 240:
pixels[x, y] = (r, g, b, 0)深色笔画主体保住了,但放到米白色主题页上看,每根笔画外围都多了一圈灰雾,尤其是细笔。
原因藏在图像的反走样(anti-aliasing)里。Procreate 导出 PNG 时,笔锋边缘不是非黑即白的硬切,而是一串从黑到白渐变的灰像素 —— 这正是数字笔刷模拟真实笔尖压感渐变的关键:
| 像素位置 | RGB 原值 | 含义 |
|---|---|---|
| 笔画核心 | (20, 20, 20) | 饱和墨色 |
| 笔画边缘 | (120, 120, 120) | 反走样过渡(压感的尾部) |
| 背景 | (255, 255, 255) | 画布底色 |
二值阈值处理之后:
- 核心黑像素(20,20,20):阈值以下,保留原 RGB、保留 alpha=255
- 边缘灰像素(120,120,120):阈值以下,保留 RGB=120 的深灰、alpha=255
- 背景白像素(255,255,255):阈值以上,alpha=0 透明
看出问题了吗?边缘那一圈中灰色像素,现在不是"半透明的黑",而是"完全不透明的灰"。 铺到原来的白画布上看不出来(灰 ≈ 白+黑的视觉平均),一换到米白/深色主题背景上,这圈灰就露馅儿了 —— 它本来就该是"黑色以不同透明度透出底色",被阈值改成"纯灰色盖住底色"。
正确的扣法:用亮度当 alpha
对于这种单色笔刷 + 纯色画布的 Procreate 作品,可以把"像素有多亮 = 背景有多透"直接映射成 alpha:
# 浅色图:黑墨 + 白底 → alpha = 255 - luminance
lum = (r + g + b) // 3
alpha = 255 - lum
pixels[x, y] = (r, g, b, alpha)
# 深色图:白墨 + 黑底 → alpha = luminance
alpha = (r + g + b) // 3
pixels[x, y] = (r, g, b, alpha)这样得到的透明 PNG,等价于"把所有像素都看作同一支笔,只是压感浓淡不同":
- 笔画核心:RGB=深色,alpha≈255(重压)
- 笔画边缘:RGB=灰,alpha≈半透明(轻压尾部,透出底色)
- 空白区域:alpha=0(完全透明)
铺到任何底色上,笔锋压感的自然过渡都能保住,反走样灰圈消失。
一个没解决的新问题
用亮度当 alpha 重新生成的浅色图,笔画确实干净了。但实际比对的时候发现,原版 PNG 在 multiply 下的笔锋比透明版更饱满一些 —— 这是因为 multiply 在数学上是"把底色和前景相乘",相当于把反走样灰像素和底色主动混一道,笔锋尾部会有一种"墨色被底色吞进去"的自然过渡。而透明 PNG 是简单的 alpha 合成,只是叠加,缺了那一层饱满感。
最后我保留了一种不对称的做法:
- 浅色图:维持原始带白底 PNG +
mix-blend-mode: multiply,吃掉浅色主题的奶油背景,笔锋保留吸墨感 - 深色图:换成透明 alpha PNG,不用混合模式,避免合成层把
screen关在外面
带背景 vs 透明通道:实现速查
| 维度 | 带背景 + mix-blend-mode | 透明 PNG(亮度→alpha) |
|---|---|---|
| 首帧闪烁 | 取决于合成层是否遮蔽混合模式 | 无,所见即所得 |
| 反走样边缘 | 由混合模式自然软化 | 取决于 alpha 映射是否线性 |
| 依赖前置条件 | 父链不能有 transform/filter/半透明 | 无,只要浏览器支持 PNG alpha |
| 处理代价 | 无,图片原样用 | 需要一步图像预处理 |
| 主题适配 | 只吃特定色相(白用 multiply,黑用 screen) | 可铺任何底色 |
| 笔锋观感 | multiply 把边缘像素和底色再混一次,饱满 | 仅 alpha 叠加,干净但稍平 |
两种路线没有绝对优劣,取舍落在是否对当前主题的背景色有确定性预期:
- 背景色固定且和素材原底色同一色系 → 用混合模式,省预处理、笔锋更自然
- 背景色会变(多主题 / 动态色)或者父节点有 transform 这类"合成层污染" → 必须透明 PNG
一点经验
mix-blend-mode很强大,但用之前先确认祖先节点里没有 transform、filter、半透明(opacity 小于 1)、will-change 之类能开启合成层的属性,否则混合模式会静默失效- 做单色笔刷素材(书法、线稿、水墨类)的透明化,不要用阈值分割。拿亮度直接当 alpha,反走样边会自动变成半透明像素,边缘自然得多
- 这类问题往往不会在 Lighthouse 或视觉回归测试里报警,它只在"刷新一次首页"的 200ms 窗口里出现。做视觉调试时,多拿几个主题来回切,把 Chrome DevTools 的 CPU 降速调到 6× 慢放看合成,问题就现形了

