ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Unity Shader后处理实现Low Poly场景水墨风格化渲染

2026/10/6 19:16:23 拓冰建站 浏览量
Unity Shader后处理实现Low Poly场景水墨风格化渲染 最近在给一个 Low Poly 风格的独立小项目做画面升级模型全是低面数硬边跑起来是很干净的几何拼贴感。美术那边临时提了个需求想在不推翻原有模型、灯光和材质的前提下让整个场景变成国风水墨画的味道而且最好直接在后处理阶段完成。刚好我一直在用 Unity Shader 写后处理特效就把这套“Low Poly 转水墨画”的方案认真打磨了一遍。今天这篇文章把整体思路、Shader 写法、参数调节和踩坑记录都整理出来如果你也想在 Unity 里做类似的水墨风格渲染可以直接照着抄。1. 方案选型为什么用后处理而不是替换材质1.1 Low Poly 场景做水墨风格卡点在哪Low Poly 的核心特征是面数低、颜色扁平、轮廓锋利这种风格本身就离水墨画的“留白、晕染、毛笔笔触”很远。如果想把每个材质都换成水墨 Shader工程量巨大而且 Low Poly 场景里的物体数量通常非常多逐个替换材质、逐个调参数既不现实也难统一。后处理就不一样它作用在屏幕空间相当于给最终画面加了一层滤镜所有物体一视同仁改动只集中在一个脚本和一份 Shader 里对原始场景零侵入。还有一个更实际的原因Low Poly 场景通常有大量纯色块面颜色过渡简单、几何边缘清晰这种输入画面做风格化反而比写实场景更省事。色块少意味着墨色量化不容易出脏色边缘硬意味着墨线提取特别稳。所以说Low Poly 转水墨画并不是硬凑反而是天然合适的搭配。1.2 水墨画面拆解你要的不只是一张灰度图在动手写 Shader 之前我先把“水墨感”拆成了几个可计算的视觉元素墨线轮廓画面中的几何边缘要被提取出来变成深色线条但线条不能是死板的黑色硬边要有“毛笔勾线”那种粗细变化和似断非断的感觉。墨色浓淡水墨不是纯黑而是从浓墨到淡墨再到留白的多个层次。对应到算法上就是色调分离加亮度映射把连续色彩压成有限的几档墨色。晕染渗化墨迹边缘要有轻微的不规则扩散模拟水在宣纸上晕开的效果。这一步可以用噪声扰动来实现。纸纹基底画面不能是干净的 RGB 颜色应该叠上一层纸的纤维纹理让颜色“吃”进纸里。后处理方案就是围绕这四个元素来做。先提取边缘再量化颜色再做扰动和纸纹合成顺序不能乱否则边缘会被噪声破坏。2. 核心实现边缘检测与墨线生成2.1 用 Sobel 算子提取屏幕空间边缘边缘检测是水墨画的骨架。我用的方案是 Sobel 算子这是最经典也最可控的梯度检测方法。后处理中通常有两种输入可以做 Sobel一种是直接对屏幕颜色算亮度梯度另一种是对深度法线纹理做梯度。颜色梯度的优点是能捕捉颜色突变缺点是 Low Poly 场景里同色物体的轮廓经常检测不出来深度法线则能稳定检测几何边缘即使两个物体颜色一样也能画出轮廓。我的做法是优先使用深度法线把两者结合。Unity 内置的_CameraDepthNormalsTexture可以通过DecodeDepthNormal同时拿到深度和法线我用深度差做主梯度再用法线差做补充防止深度接近但朝向不同的面漏检。Shader 里核心代码如下sampler2D _MainTex; sampler2D _CameraDepthNormalsTexture; float4 _MainTex_TexelSize; float EdgeSobel(float2 uv) { float2 texel _MainTex_TexelSize.xy; float depth[9]; float3 normal[9]; for (int i -1; i 1; i) { for (int j -1; j 1; j) { float d; float3 n; DecodeDepthNormal(tex2D(_CameraDepthNormalsTexture, uv float2(i, j) * texel), d, n); depth[(i 1) * 3 (j 1)] d; normal[(i 1) * 3 (j 1)] n; } } float gxDepth depth[0] 2 * depth[3] depth[6] - depth[2] - 2 * depth[5] - depth[8]; float gyDepth depth[0] 2 * depth[1] depth[2] - depth[6] - 2 * depth[7] - depth[8]; float edgeDepth sqrt(gxDepth * gxDepth gyDepth * gyDepth); float3 gxNormal normal[0] 2 * normal[3] normal[6] - normal[2] - 2 * normal[5] - normal[8]; float3 gyNormal normal[0] 2 * normal[1] normal[2] - normal[6] - 2 * normal[7] - normal[8]; float edgeNormal length(cross(gxNormal, gyNormal)); return saturate(edgeDepth * 8.0 edgeNormal * 2.0); }这里有个容易被忽略的细节_CameraDepthNormalsTexture默认情况下不包含透明物体的深度信息。如果场景里有大量透明草、透明粒子后处理边缘检测会把它们漏掉。我做的是 Low Poly 风格透明物体不多所以问题不大但如果你场景里有透明物件需要先把它们烘焙进深度纹理或者改用颜色梯度的 Sobel 作为补充。2.2 把边缘强度变成可控的墨线Sobel 输出的是一个 0 到 1 之间的边缘强度值直接拿它当线条会显得又脏又碎。我的经验是用smoothstep做阈值过滤把弱边缘全部丢掉保留强边缘并且用两个阈值制造“反差”让线有粗细变化。float edge EdgeSobel(uv); float line 1.0 - smoothstep(_EdgeThreshold - _EdgeSoftness, _EdgeThreshold _EdgeSoftness, edge);_EdgeThreshold控制阈值_EdgeSoftness控制软硬程度。水墨画的线不能是纯黑硬边我会把线条颜色用_InkColor控制默认偏深灰蓝而不是纯黑float3 inkColor lerp(_InkColor, _PaperColor, line);同时我会给线条加一点点抖动基于屏幕坐标做frac(sin(...))的随机扰动让线条在某些像素上偶然断开。这样出来的线就不是打印出来的黑线而是有手绘那种“飞白”感。注意抖动强度要极小否则画面会闪。3. 墨色分层、晕染与纸纹合成3.1 色调分离把 Low Poly 色块压成墨色层次水墨画的颜色不是连续渐变的而是几个清晰的墨阶。这里我用的就是最朴素的量化算法对颜色亮度做阶梯化处理把连续的亮度空间压成 3 到 5 个等级然后再从“留白、淡墨、中墨、浓墨”四个颜色里插值。float luma dot(tex2D(_MainTex, uv).rgb, float3(0.299, 0.587, 0.114)); float quantized floor(luma * _QuantizeLevels) / _QuantizeLevels; float3 paperColor tex2D(_PaperTex, uv * _PaperTile).rgb; float3 baseColor lerp(_PaperColor, paperColor * _InkColor, quantized);这里有个设计上的取舍等级数不能太多太少了画面糊成一团太多了又不像水墨。我实测下来 Low Poly 场景用 4 档最舒服分别是 0.0、0.33、0.66、1.0对应浓墨、中墨、淡墨和留白。具体档位还要看你的模型亮度分布如果场景偏暗就把 1.0 这档往 0.8 挪避免整幅画面死黑。有朋友可能会问为什么不直接用原色做淡彩水墨而要压成墨色因为淡彩水墨虽然也好看但它和 Low Poly 原本的扁平配色容易混在一起视觉上缺少“墨分五色”的层次感。纯墨色方案反而能拉开画面深度。3.2 边缘区域的噪声扰动模拟宣纸渗墨水墨最难的模仿环节是晕染。颜色不是老老实实待在多边形边界里而是会朝外面渗出去一点。我采用的做法是先拿到上一步算出来的边缘强度在边缘附近对采样坐标做噪声扰动扰动幅度由边缘强度控制这样多边形内部保持干净只有靠近轮廓的地方出现毛毛躁躁的渗墨效果。float2 noiseUV uv (noise(uv * _NoiseScale) - 0.5) * _NoiseAmount * edge; float3 color tex2D(_MainTex, noiseUV).rgb;这里的噪声我建议用一张 256x256 的平滑噪声贴图而不是纯程序噪声。纯程序噪声在高分辨率屏幕上容易出颗粒感尤其移动端明显。平滑噪声贴图配合较大的_NoiseScale看起来才是水的渗化感而不是电视雪花。还有个细节不要对整张图做统一的噪声扰动。我一开始就是全局扰动结果 Low Poly 模型的棱角全糊了整个画面像蒙了一层雾。后来改成只在edge 0.3的位置做扰动画面立刻就干净了。3.3 纸纹合成让墨色吃进纸里最后一步是纸纹叠加。水墨画离不开纸的肌理直接叠一张宣纸纹理就能把数字感压下去。但叠加要控制强度叠太强画面发灰叠太弱又没效果。我的做法是把纸纹放在最后混合阶段用_PaperStrength控制混合比例同时用亮度做权重让暗部墨迹更吃纸亮部留白更突出纸张本身的颜色float3 paper tex2D(_PaperTex, uv * _PaperTile).rgb; float paperMask 1.0 - quantized; finalColor lerp(finalColor, paper * _PaperColor, _PaperStrength * paperMask);纸纹的 UV 平铺次数_PaperTile我一般设成 1 到 2不要太高否则纹理在屏幕上会显得像重复瓷砖。我习惯把纸纹做成 2048 分辨率的扫描图叠加时关闭它的颜色贡献只保留亮度通道这样纸张本身的底色不会污染墨色。4. 参数配置与调整技巧4.1 核心参数速查表我把整个后处理 Shader 暴露的参数整理成了下面这张表方便你直接抄。参数作用推荐范围备注_EdgeThreshold边缘检测阈值0.4 ~ 0.8值越小边缘越密_EdgeSoftness边缘羽化0.05 ~ 0.2太大线条发虚_QuantizeLevels墨色层级数3 ~ 54 档效果最稳_InkStrength墨色浓度0.7 ~ 1.3调高暗部更黑_NoiseScale噪声频率20 ~ 80越大晕染越碎_NoiseAmount噪声强度0.01 ~ 0.05过大会糊_PaperStrength纸纹混合强度0.05 ~ 0.25过强画面发灰_PaperTile纸纹平铺1 ~ 2结合屏幕分辨率调整4.2 从零开始调一套能看的参数我的调试方法是从默认值开始先把画面调成“看得过去”再逐项精细化。第一步把边缘检测单独输出到画面只调_EdgeThreshold和_EdgeSoftness直到墨线清楚且不碎。第二步关闭纸纹和噪声把_QuantizeLevels调到 4观察墨色层次是否自然。第三步开噪声调_NoiseScale和_NoiseAmount瞄准模型棱角观察渗化效果。最后再叠加纸纹调_PaperStrength。整个过程我习惯在 Game 视图里实时调后处理 Shader 的好处是改了参数立刻能看到结果不像材质替换那么麻烦。4.3 性能优化别让全屏 Pass 吃光帧率后处理本质上是多一个全屏 Pass性能开销取决于采样次数。我这套 Shader 里最重的部分是 Sobel 的 9 次深度法线采样再加上主纹理采样、噪声采样和纸纹采样整体大约 13 次纹理采样在 PC 上毫无压力但在移动端就需要小心。如果你要跑手机游戏有几个降耗手段。一是降分辨率在OnRenderImage里先把画面降到 0.5 倍做完后处理再升回去。二是把纸纹和噪声合成到同一张纹理里减少一次采样三是减少 Sobel 的邻域数量从 3x3 改成十字采样边缘会稍微粗糙一点但能省下一半采样量。我自己在移动端测试时用 0.5 倍分辨率加十字 Sobel帧率能稳在 60画质损失基本看不出。5. 常见问题与排查记录5.1 边缘断线、噪点、发灰的解决方案我在实际调试中踩过不少坑整理成一张速查表遇到问题直接对照着查。现象原因解决方案墨线断断续续边缘阈值过高降低_EdgeThreshold加大深度梯度权重画面布满细碎噪点噪声扰动作用于全屏改为只在边缘区域扰动降低_NoiseAmount整张画面发灰纸纹叠加过强降低_PaperStrength或提高纸纹亮度阈值墨色层次不明显_QuantizeLevels偏高偏低先用 4 档观察暗部是否发闷透明物体不参与边缘检测深度法线纹理不含透明改用颜色梯度 Sobel 或额外烘焙深度屏幕边缘一圈发黑后处理采样越界边缘像素用 clamp 采样或把_MainTex_TexelSize乘 0.55.2 关于管线兼容性的一点提醒这套后处理 Shader 在 Unity 内置渲染管线上最省事直接写OnRenderImage就行。但如果你用的是 URP 或 HDRP就不能直接挂脚本了需要把后处理逻辑改成 Scriptable Renderer Feature用 CommandBuffer 做 Blit。URP 里因为不再默认提供_CameraDepthNormalsTexture你需要先开启 Depth Texture 或 DepthNormals Pass。如果项目要发 WebGL还要注意 Shader 里别用太多采样器某些 WebGL 设备对 sampler 数量有限制。我的做法是纸纹和噪声合并后用数组纹理或通道打包减少 sampler 占用。另外Linear 颜色空间下墨色会偏淡记得在 Shader 里乘一个_InkStrength补偿。5.3 调试技巧用 Frame Debugger 看中间结果后处理 Shader 因为是全屏 Pass出问题时很难直接看出是哪一步出了问题。我强烈建议你善用 Frame Debugger把EdgeSobel的中间结果输出到一张 Debug RT 上可视化看看边缘提取得干不干净。我的习惯是写一个开关变量_DebugMode值为 1 时输出边缘图值为 2 时输出量化图值为 3 时输出最终图。这样调试效率能提高一个量级不用频繁注释代码。这个技巧帮我节省了大量时间尤其是边缘断裂问题光靠猜参数很难定位看中间结果一眼就知道是阈值问题还是深度图缺失问题。我个人在实际操作中的体会是水墨风格最忌讳“什么都想要”。一开始我把边缘、墨色、晕染、纸纹全开到最大结果画面脏到没法看。后来把每个效果都做“减法”边缘只留主要轮廓墨色只留四档晕染只做在边缘附近纸纹只叠 10% 强度整体反而一下子就对了。最后再分享一个小技巧如果你的场景天空盒太亮Low Poly 的远山会变成一片惨白可以在后处理前先压一压天空盒亮度或者干脆把天空盒关掉水墨留白交给后处理去控制效果会纯粹很多。