Unity Shader实现Photoshop图层混合:从原理到工程实践

📅 2026/7/9 21:43:59
Unity Shader实现Photoshop图层混合:从原理到工程实践
1. 项目概述为什么要在Unity里复刻Photoshop的图层混合如果你同时是游戏开发者和视觉设计师或者你正在制作一款对画面表现力有极高要求的游戏那你一定遇到过这样的困境在Photoshop里精心调校出的、由多个图层叠加混合而成的惊艳视觉效果到了Unity里却难以重现。你可能会尝试用多个UI Image叠加、调整Material的Blend Mode或者写一些简单的颜色混合Shader但结果总是差那么点意思——颜色不够通透、叠加效果生硬或者性能开销巨大。这个项目的核心就是要彻底打通从Photoshop到Unity的视觉工作流。它不仅仅是把几个Blend Mode枚举值搬过来而是深入到每一种混合模式的数学本质用Shader代码精准地复现其行为。这对于需要高度风格化渲染的独立游戏、追求电影级画面的AAA项目甚至是需要动态合成复杂UI特效的应用都有着不可替代的价值。想象一下你的游戏角色技能特效能像PS图层一样灵活地“正片叠底”、“滤色”或“叠加”在动态变化的场景上并且完全由GPU实时计算这能释放多大的创意空间我最初做这个是因为一个卡牌游戏的技能特效需求。美术给了一堆用PS做的、包含复杂图层样式的特效图但要求这些特效的颜色需要根据游戏状态实时变化。如果只用静态图片要么做多套图资源爆炸要么效果打折。最终我们把PS的混合公式搬进了Shader让美术直接在Unity里用熟悉的“图层”逻辑配置特效问题迎刃而解。接下来我就把这套从公式推导到代码落地的完整经验拆开揉碎了讲给你听。2. 核心原理图层混合的数学本质与GPU着色逻辑在动手写代码之前我们必须先理解PS图层混合到底在算什么。很多人以为混合就是简单的A * B或者A B其实远不止如此。每一种混合模式都是一个定义明确的数学函数它接收两个输入底层颜色Base Color和上层颜色Blend Color然后输出一个结果颜色。2.1 颜色空间与通道处理的基础一个最容易被忽略的关键前提是Photoshop的绝大多数混合模式是在“预乘”的、非线性的sRGB颜色空间下进行的计算。而Unity Shader中我们通常是在线性空间Linear Space下操作颜色值。如果你在Unity中设置了项目为线性空间渲染但直接把PS的公式套用在线性颜色上结果会明显不对。这是因为像“叠加”、“柔光”这类模式其数学曲线如对比度调整是为感知均匀性设计的在线性空间下会过曝或发灰。重要提示在实现时一个务实的做法是在Shader中先将输入的线性颜色转换到近似sRGB空间使用pow(color, 2.2)进行粗略的伽马校正执行混合计算然后再转换回线性空间pow(color, 1/2.2)。对于追求绝对精确的场合需要使用精确的sRGB到线性的转换函数。但根据我的经验对于大多数艺术导向的应用在混合前进行一次简单的pow(color, 0.454545)即约1/2.2转换到近似线性计算后再转回sRGB就能获得非常接近PS的效果且性能更好。2.2 27种混合模式的分类与核心思想PS的混合模式虽多但按其数学行为可以归纳为几大类理解类别能帮你更好地记忆和实现变暗组结果永远不会亮于底层。核心是取最小值或进行某种形式的乘法。变暗min(Blend, Base)正片叠底Blend * Base。这是最常用也是最重要的模式之一模拟光线透过两张叠在一起的幻灯片结果总是更暗。颜色加深1 - (1 - Base) / Blend。增加对比度使底层变暗白色部分无影响。变亮组结果永远不会暗于底层。是变暗组的反相操作。变亮max(Blend, Base)滤色1 - (1 - Blend) * (1 - Base)。可以理解为Blend Base - Blend * Base。模拟将两张幻灯片分别用两台投影仪投射到同一屏幕上结果总是更亮。是“正片叠底”的互补模式。颜色减淡Base / (1 - Blend)。增加对比度使底层变亮黑色部分无影响。对比度组同时包含变暗和变亮操作基于底层的亮度。叠加根据底层亮度对上层进行“正片叠底”或“滤色”。公式if(Base 0.5) then 2 * Blend * Base else 1 - 2 * (1 - Blend) * (1 - Base)。这是另一个极其重要的模式能保留底层的高光和阴影。柔光比叠加更温和的版本。公式类似但使用了不同的曲线。强光将底层作为混合色应用叠加模式。相当于交换了叠加模式的输入。比较组突出差异。差值abs(Base - Blend)。产生负片效果。排除Base Blend - 2 * Base * Blend。比差值对比度更低更柔和。色彩组件组将颜色的色相、饱和度、明度HSL/HSV或亮度、色度YCbCr分离处理。色相输出上层的色相保留底层的饱和度和明度。饱和度输出上层的饱和度保留底层的色相和明度。颜色输出上层的色相和饱和度保留底层的明度。常用于黑白照片上色。明度输出上层的明度保留底层的色相和饱和度。是“颜色”模式的相反操作。理解这些核心思想后你会发现很多模式的Shader实现其实就是一两行代码的事。难点在于精度、颜色空间和性能的权衡。3. 开发环境与Shader框架搭建我们不从零开始造轮子。Unity提供了强大的Shader编写框架这里我推荐使用Unity URPUniversal Render Pipeline的Unlit Shader Graph或自定义HLSL代码作为起点。URP现在是Unity的主流和未来兼容性好功能也足够。对于需要极致性能控制的场景当然也可以写Built-in管线或自定义SRP的Shader但URP能覆盖90%的需求。3.1 创建基础Shader与属性定义首先在Unity中创建一个新的Unlit Shader或者从URP的Unlit模板开始修改。我们需要定义几个关键属性Properties让美术或策划可以在材质面板上调节Properties { _BaseMap (Base Texture, 2D) white {} _BaseColor (Base Color, Color) (1,1,1,1) _BlendMap (Blend Texture, 2D) white {} _BlendColor (Blend Color Tint, Color) (1,1,1,1) _BlendMode (Blend Mode, Int) 0 // 使用枚举下拉菜单 _BlendOpacity (Blend Opacity, Range(0, 1)) 1.0 }这里_BlendMode我们计划用一个整数来代表27种不同的混合模式。为了在材质面板上显示友好的下拉菜单我们需要配合一个自定义的Enum属性绘制器或者用Shader Graph的Enum节点。在Shader代码中我们可以用一个#pragma multi_compile变体来处理但更简单高效的做法是在片元着色器里用一个大的switch(_BlendMode)语句。3.2 设计核心混合函数库这是项目的核心。我们将为每一种混合模式编写一个独立的函数。这些函数都遵循相同的签名float3 BlendModeX(float3 base, float3 blend)。我建议创建一个单独的HLSL Include文件比如PhotoshopBlendModes.hlsl来存放所有这些函数。这样做的好处是代码复用性极高你可以在不同的Shader中通过#include来使用这个函数库。例如正片叠底和滤色的实现简单得令人发指// PhotoshopBlendModes.hlsl #ifndef PHOTOSHOP_BLEND_MODES_INCLUDED #define PHOTOSHOP_BLEND_MODES_INCLUDED // 正片叠底 (Multiply) float3 BlendMultiply(float3 base, float3 blend) { return base * blend; } // 滤色 (Screen) float3 BlendScreen(float3 base, float3 blend) { return 1.0 - (1.0 - base) * (1.0 - blend); } // 叠加 (Overlay) float3 BlendOverlay(float3 base, float3 blend) { // 分量计算 return float3( (base.r 0.5) ? (2.0 * blend.r * base.r) : (1.0 - 2.0 * (1.0 - blend.r) * (1.0 - base.r)), (base.g 0.5) ? (2.0 * blend.g * base.g) : (1.0 - 2.0 * (1.0 - blend.g) * (1.0 - base.g)), (base.b 0.5) ? (2.0 * blend.b * base.b) : (1.0 - 2.0 * (1.0 - blend.b) * (1.0 - base.b)) ); } // ... 其他24个模式的函数 #endif注意像“叠加”这样的模式需要对RGB三个通道分别进行判断和计算不能直接把float3当成一个整体做if-else。3.3 颜色空间转换的封装如前所述颜色空间是关键。我们可以在函数库的开头或主Shader中封装两个辅助函数// 简化版线性到sRGB用于混合计算 float3 LinearToSRGBApprox(float3 lin) { return pow(lin, 0.454545); // 1/2.2 } // 简化版sRGB到线性用于混合后输出 float3 SRGBToLinearApprox(float3 srgb) { return pow(srgb, 2.2); }然后在主混合函数里这样用float3 ApplyPhotoshopBlend(float3 baseLinear, float3 blendLinear, int mode, float opacity) { // 1. 转换到近似sRGB空间进行混合 float3 baseSRGB LinearToSRGBApprox(baseLinear); float3 blendSRGB LinearToSRGBApprox(blendLinear); // 2. 执行混合计算 float3 resultSRGB baseSRGB; switch(mode) { case 0: resultSRGB BlendMultiply(baseSRGB, blendSRGB); break; case 1: resultSRGB BlendScreen(baseSRGB, blendSRGB); break; case 2: resultSRGB BlendOverlay(baseSRGB, blendSRGB); break; // ... 其他case default: break; } // 3. 混合结果与原始底层进行透明度混合 resultSRGB lerp(baseSRGB, resultSRGB, opacity); // 4. 转换回线性空间输出 return SRGBToLinearApprox(resultSRGB); }这个ApplyPhotoshopBlend函数就是我们的总调度器。4. 关键混合模式的代码实现与深度解析让我们挑几个最有代表性、也最容易出错的混合模式看看它们的完整实现细节和背后的原理。4.1 “叠加”与“柔光”基于亮度的条件分支“叠加”模式是理解对比度组的关键。它的逻辑是如果底层颜色较暗0.5则进行正片叠底式的变暗如果底层颜色较亮0.5则进行滤色式的变亮。这相当于把上层图案的亮部映射到底层的亮部区域暗部映射到底层的暗部区域从而增强对比度并保留底层的光影结构。代码实现如上节所示但这里有个性能坑点在Shader中每个像素的RGB通道都要进行一次if判断。虽然现代GPU对分支的处理能力很强但在某些低端移动设备上分支仍可能带来性能波动。一个常见的优化技巧是使用“无分支”的数学等价形式float3 BlendOverlayNoBranch(float3 base, float3 blend) { // 这个公式是叠加模式的另一种数学表达避免了显式的if-else return lerp(2.0 * base * blend, 1.0 - 2.0 * (1.0 - base) * (1.0 - blend), step(0.5, base)); // step函数生成0或1 }step(a, x)函数在xa时返回1否则返回0。lerp(x, y, a)函数根据a在x和y之间线性插值。这样就把条件判断转化为了一个混合操作在某些架构上可能更高效。但要注意这种写法可能因为精度问题与PS的官方结果有极其细微的差别美术通常肉眼不可辨。“柔光”模式公式类似但曲线更平缓if(Base 0.5) then 2 * Blend * Base Base * Base * (1 - 2 * Blend) else 2 * Base * (1 - Blend) sqrt(Base) * (2 * Blend - 1)。看起来复杂但翻译成代码就是照搬。我建议你直接把这个公式封装成函数。4.2 “颜色减淡”与“颜色加深”警惕除零问题这两个是“问题儿童”。它们的公式涉及除法颜色减淡result base / (1 - blend)颜色加深result 1 - (1 - base) / blend当分母为0时Shader计算会产生NaN非数字或Inf无穷大导致画面出现黑色或紫色斑块取决于GPU。必须进行边界保护float3 BlendColorDodge(float3 base, float3 blend) { // 保护当blend接近1时结果应为白色或接近白色 float3 result base / (max(0.0001, 1.0 - blend)); return min(result, 1.0); // 同时钳制结果防止过亮 } float3 BlendColorBurn(float3 base, float3 blend) { // 保护当blend接近0时结果应为黑色 float3 result 1.0 - (1.0 - base) / max(0.0001, blend); return max(result, 0.0); // 钳制结果 }这里用max(denominator, 0.0001)来避免除零并用min/max将结果限制在 [0, 1] 范围内。这是工业级Shader必须做的鲁棒性处理。4.3 “色相/饱和度/颜色/明度”模式色彩空间转换的奥秘这组模式需要将RGB颜色转换到HSL或HSV色彩空间交换其中一个或两个分量再转换回来。这里有一个重大抉择用HSL还是HSVPhotoshop官方文档使用的是HSL色相、饱和度、亮度模型。但HSL的亮度Lightness定义与我们的感知并非完全线性。一个更接近PS效果、计算也更高效的方法是使用HCY色相、色度、亮度或HSV色相、饱和度、明度色彩空间。经过我的反复测试使用HSV并在转换时做一些微调能得到最接近PS的结果。以下是HSV转换的参考实现// RGB转HSV float3 RGBToHSV(float3 c) { float4 K float4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0); float4 p lerp(float4(c.bg, K.wz), float4(c.gb, K.xy), step(c.b, c.g)); float4 q lerp(float4(p.xyw, c.r), float4(c.r, p.yzx), step(p.x, c.r)); float d q.x - min(q.w, q.y); float e 1.0e-10; return float3(abs(q.z (q.w - q.y) / (6.0 * d e)), d / (q.x e), q.x); } // HSV转RGB float3 HSVToRGB(float3 c) { float4 K float4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); float3 p abs(frac(c.xxx K.xyz) * 6.0 - K.www); return c.z * lerp(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y); } // “颜色”模式实现取上层的色相和饱和度底层的明度 float3 BlendColor(float3 base, float3 blend) { float3 baseHSV RGBToHSV(base); float3 blendHSV RGBToHSV(blend); // 组合H和S来自blendV来自base float3 resultHSV float3(blendHSV.x, blendHSV.y, baseHSV.z); return HSVToRGB(resultHSV); }“色相”、“饱和度”、“明度”模式的实现同理只是交换的分量不同。注意当饱和度为零时灰度色色相是未定义的。PS的处理方式是让结果色也为灰度。在我们的代码中RGBToHSV函数在饱和度为0时会返回一个默认色相通常是0这可能导致非预期的轻微色偏。更严谨的做法是在转换前判断饱和度如果很低则直接输出底层或上层的灰度值。5. 在Shader Graph中的可视化搭建方案如果你不习惯写代码或者想给团队里的技术美术提供一个更友好的工具Shader Graph是绝佳选择。虽然需要手动连接节点来构建每一个混合模式但它的可视化和实时预览能力无与伦比。5.1 构建可复用的子图Sub Graph对于每一种混合模式我强烈建议你创建一个Sub Graph。例如创建一个名为“SG_PhotoshopMultiply”的子图它有两个Vector3输入Base Blend和一个Vector3输出Result内部就是一个Multiply节点。然后为“叠加”、“滤色”等模式都创建对应的子图。接下来创建一个主Shader Graph。使用一个Integer或Enum节点作为混合模式的选择器然后连接一个Switch节点或一系列Branch节点。Switch节点的每个端口连接一个不同的混合模式子图。这样通过下拉菜单选择模式就能实时切换混合效果。5.2 处理颜色空间问题Shader Graph默认在节点内部处理的是线性空间颜色。为了模拟PS你需要手动添加颜色空间转换节点。有两种方法使用内置节点在URP的Shader Graph中有Linear To SRGB和SRGB To Linear节点。你可以在混合计算前将Base和Blend颜色都经过Linear To SRGB节点计算后的结果再通过SRGB To Linear节点转换回去。使用幂函数近似如上所述使用Power节点分别设置指数为0.454545和2.2来模拟转换。实操心得在Shader Graph中调试颜色空间问题非常直观。你可以先不加转换看看效果差多远然后加上转换对比PS里的截图。通常在混合节点前后各加一对转换节点就能解决大部分色差问题。记得把转换逻辑也封装到每个混合模式的子图内部保持主图的整洁。5.3 性能考量与变体生成在Shader Graph中如果你使用了基于Enum的Switch并且这个Enum暴露为材质属性Unity在构建时可能会为每一种枚举值生成一个Shader变体。这意味着如果你有27种模式理论上最多可能生成27个变体这会增加包体和内存占用。优化建议如果项目只用其中固定的几种模式不要在材质球上暴露一个包含所有27种模式的Enum。可以创建多个不同的Shader Graph资产每个资产只实现需要的几种模式。或者使用一个自定义的Keyword下拉菜单并确保在Graph的“Blackboard”中为这些Keyword设置正确的“Shader Graph Settings”避免生成不需要的变体。对于移动端项目要警惕那些计算复杂的模式如涉及HSV转换、多次条件判断的在低端机上测试其性能影响。6. 性能优化、常见问题与调试技巧将27种模式全部实现并投入项目使用不能只关注效果性能和稳定性是生命线。6.1 性能优化策略按需编译不要在一个Shader里包含所有27种模式的代码然后用Switch。如果项目只用“正片叠底”、“滤色”、“叠加”和“颜色”这四种就只编译这四种。可以通过Shader的#pragma multi_compile指令定义多个关键字然后根据材质使用的关键字来编译不同的代码路径。这是最有效的优化。简化色彩空间转换对于非色彩组件组即前20种左右的模式可以尝试完全在线性空间计算并与PS结果对比。有时差异在可接受范围内特别是对于风格化渲染。这能省去两次pow运算。避免全屏使用这些混合Shader如果用在覆盖全屏的后期处理或UI上计算量会乘以屏幕像素数。考虑使用更简化的混合或者将混合操作下采样到一半分辨率再进行。预处理静态纹理如果上层混合纹理和混合模式是固定的可以考虑在Asset导入管线或运行时用CPU预先计算好混合结果生成一张新的纹理从而将运行时GPU的逐像素计算变为一次纹理采样。6.2 常见问题与解决方案速查表问题现象可能原因解决方案混合结果整体发白、过曝颜色空间错误在线性空间下使用了为sRGB设计的公式在混合计算前后添加sRGB与线性空间的转换。混合结果与PS对比有轻微色差1. 转换公式精度不足如用了近似的2.2伽马2. PS使用了不同的色彩空间如Adobe RGB3. HSV/HSL模型选择问题1. 使用精确的sRGB转换函数。2. 确认PS文档和项目设置的颜色空间一致。3. 尝试换用HCY色彩空间实现“色相”等模式。画面出现黑点、紫块NaN除零错误如颜色减淡模式中blend为1在除法前对分母加一个极小值如1e-5进行保护并对结果进行saturate()钳制。在移动设备上帧率下降明显1. 使用了复杂的逐像素分支if-else2. 使用了昂贵的函数如pow,sin,cos在HSV转换中3. 变体过多导致Shader切换开销大1. 尝试用lerpstep重写分支。2. 对于HSV转换考虑使用查找表LUT纹理。3. 合并Shader减少变体数量。“色相”模式在灰色区域产生色偏饱和度接近0时色相值无意义但转换函数仍返回了一个默认值在转换前判断饱和度若低于阈值如0.01则直接输出底层颜色或灰度值。Shader编译错误或变体爆炸Switch节点或Enum属性导致了过多的Shader变体在Shader Graph设置中检查变体关键字或改用动态分支Branch节点但注意动态分支可能有性能代价。6.3 调试技巧如何与Photoshop结果像素级对比建立测试基准在PS中创建两个纯色图层使用你要测试的混合模式导出结果为PNG。在Unity中创建两个纯色材质球应用你的Shader并设置相同的RGB值。使用帧调试器Unity的Frame Debugger可以捕获游戏某一帧的渲染状态。找到你材质球绘制的那个DrawCall查看输出的像素值。将其与PS中通过吸管工具获取的像素值进行比较。输出中间值在Shader中你可以临时将中间计算值如转换后的sRGB颜色、HSV分量输出为颜色以可视化检查哪一步出了问题。例如将float3 hsv RGBToHSV(base);的结果直接return float4(hsv, 1.0);来查看色相、饱和度、明度通道的图像。编写单元测试对于核心的混合函数可以编写一个简单的C#脚本在Editor模式下运行用一组预定义的输入值调用这些函数需要将HLSL代码翻译成C#并与从PS导出的标准答案进行数值比较输出差异报告。7. 高级应用与扩展思路当你掌握了基础实现后这些混合模式可以成为你渲染工具箱里的瑞士军刀玩出更多花样。动态混合与动画混合模式的选择、上层纹理的UV偏移、透明度都可以做成随时间变化的动画。例如让一个“滤色”模式的光效纹理UV滚动模拟能量流动或者让“叠加”模式的图层透明度脉冲变化制造呼吸感。多层混合的串联PS的强大在于可以堆叠无数个图层。在Shader中我们也可以模拟这一点虽然性能成本会线性增长。你可以设计一个支持2-4层混合的Shader每层都有独立的纹理、颜色、混合模式和透明度。通过Shader Graph的Sub Graph嵌套或者代码中的函数串联来实现。这对于复刻复杂的PSD设计稿至关重要。在粒子系统与VFX Graph中的应用这是杀手级应用。在粒子的Renderer模块中使用我们自定义的混合Shader可以让粒子之间、粒子与背景之间按照PS的逻辑进行混合。比如烟雾粒子用“正片叠底”来加深阴影火花粒子用“滤色”来提亮高光这比标准的Alpha Blending效果更丰富、更物理。与后处理栈Post Processing结合你可以创建一个全屏的后处理效果将屏幕内容作为一个图层与另一张全局的遮罩或渐变纹理进行混合。例如用“叠加”模式混合一张噪波纹理来模拟胶片颗粒或者用“颜色”模式为整个场景统一叠加一个色调实现风格化的色彩分级。实现这个项目的过程中我最深的体会是理论和实践之间隔着一道名为“细节”的鸿沟。从数学公式到可用的Shader代码每一步都可能遇到坑——颜色空间的陷阱、除零的崩溃、性能的悬崖、与参考软件那微妙的色差。但每解决一个这样的问题你对图形学的理解就会深一层。最终当你在Unity里看到那个和Photoshop里一模一样的混合效果时那种打通了两个世界壁垒的成就感是无与伦比的。现在你的创意将不再受工具的限制。