☰
Unity Shader后处理:将Low Poly模型转换为水墨画风格
2026/10/6 13:23:18 网站建设 项目流程

相信不少做风格化渲染的同学,第一次看到“把Low Poly模型变成水墨画”这个需求时,心里都会咯噔一下。Low Poly的精髓是清晰的大块面、硬朗的棱线、明快的色彩过渡,而水墨画讲究的是“似与不似之间”的朦胧感、墨色晕染的随机性以及笔触的书写感——这两个风格在视觉语言上几乎是反着来的。但正因如此,用Unity Shader做后处理把两者桥接起来,才特别有意思。我先说结论:这个效果完全可行,甚至不需要改动任何模型资源,只靠屏幕空间的后处理栈,就能把低模场景“揉”成一幅动态的、带有墨韵的画卷。

这篇博文会从思路、原理、实操到踩坑,完整走一遍我在项目中实现的方案。你不需要是图形学大牛,但至少要能看懂C#和Shader的基础语法,知道后处理OnRenderImage或CommandBuffer的大致流程。如果你正准备做国风游戏、水墨风特效,或者单纯想在作品集里放一个风格化Demo,这篇文章应该能帮你省下几个星期的试错时间。

1. 内容整体设计与思路拆解

1.1 为什么选择后处理,而不是直接改材质

一开始我也纠结过要不要直接写一套水墨风Surface Shader或Unlit Shader,把模型本身的渲染风格改掉。试了一周之后我放弃了,原因很实际:Low Poly场景往往有成百上千个物体,逐个替换材质、处理描边、协调透视关系,工作量巨大,而且一旦某个物体漏改,画面风格就断裂了。更关键的是,水墨画很看重“整体感”——远山、近石、树木、云雾,它们之间的墨色浓淡和虚实关系是互相呼应的,这种全局氛围用逐物体材质几乎不可能统一。

后处理方案则完全不同。它把整个画面当成一幅“待画的宣纸”,拿到的是屏幕空间最终的像素和深度信息。在这个层面,可以做全局的边缘提取、亮度分层、颜色抽离、噪声扰动,甚至局部模糊。Low Poly所有几何体的棱角特征会被整体感知并转译成“笔触”,风格一致性好,也方便动态调节墨色浓淡。

1.2 目标效果拆解:从“看得清Low Poly”到“看得出水墨画”

后处理不是简单加个滤镜,它得先回答一个问题:什么样的画面在人眼看来是“水墨画”而不是“黑白卡通”或“模糊照片”?我拆解了参考图之后,总结出水墨画区别于其他风格的核心特征:

  • 墨色分层:画面并不是单纯去色,而是保留微弱的色相偏向,通常偏向青灰或赭石,同时以浓墨、淡墨、焦墨几个梯度区分前后景。
  • 边缘“写意”而非“写实”:传统卡通描边是干净的粗黑线,水墨的边缘则是断续的、带有深浅变化的,像毛笔水分不够时飞白的感觉。
  • 内部细节被“揉”掉:Low Poly里那些清晰的三角面高光、锐利的明暗交界线,在水墨里会被晕染和扩散,变成大块灰度。
  • 随机性:宣纸吸收墨汁不是均匀的,同一遍渲染两帧之间也会有微妙的墨迹差异。
  • 留白与空气感:远景往往直接淡出到纸色,而不是像雾一样均匀变灰。

所以这个后处理的本质,是基于深度、法线、亮度、噪声这四类信息,对屏幕图像做四次“翻译”:边缘化、墨色分层、局部模糊、抖动留白。下面逐个展开。

2. 核心细节解析与实操要点

2.1 在Unity中搭建后处理管线的基础配置

我用的是Unity 2021.3 LTS,URP管线。如果你用内置渲染管线,思路完全一致,只是获取深度法线纹理的方式略有不同。先罗列基础配置:

  • 将相机设置为Depth Texture模式,URP下在相机组件里勾选Depth Texture。
  • 后处理脚本挂在相机上,在OnRenderImage里执行,或者在URP里写一个ScriptableRendererFeature。为了快速原型验证,我推荐先在OnRenderImage里做,调试起来最快。
  • Shader写入后处理材质,使用Graphics.Blit进行全屏Pass。

我记得第一版踩过一个隐性坑:URP默认的相机输出颜色空间是Gamma还是Linear,会直接影响水墨的灰度感觉。建议项目使用Linear空间,墨色层次更接近真实墨水叠加的效果,Gamma空间下中间调容易发闷。另外,摄像机的HDR选项建议开启,因为后面做Bloom变体或高光抽离时会用到超过1.0的亮度值,不开HDR高光部分会直接被截断。

2.2 边缘提取的两种实现路线:Sobel算子与深度法线检测

水墨画的边缘和卡通描边不一样,不能直接拿亮度差做Sobel就完事,那样出来的线条又齐又死,像复印出来的。我试过两种方案:

第一种是颜色亮度Sobel。把屏幕图转灰度,然后用3x3卷积核对每个像素做梯度计算。优点是实现简单、不容易漏边缘,缺点是Low Poly物体内部的明暗交界线也会被提取出来,导致画面被密密麻麻的线网覆盖,水墨的“留白感”全没了。

第二种是深度法线边缘检测。只根据深度差和法线夹角去提取轮廓,这样模型内部平滑的明暗过渡不会被误判为边缘,提取出来的线条主要集中在物体的几何轮廓上。Low Poly模型因为面大且平,深度法线变化明显,正好适合这种方案。我的最终Shader里两种方案合并使用,但权重不同:深度法线边缘作为主轮廓,亮度边缘仅用于提取极暗的浓墨区域。

在实际参数调整上,深度边缘的阈值在0.3到0.8之间调节,法线夹角阈值在0.15到0.5之间。具体取多大,得看场景里物体的大小。如果你场景里有一些特别小的物体,建议降低深度阈值,否则远处的小石头边缘会被直接忽略。

2.3 墨色分层与去饱和的平衡术

Low Poly场景之所以好看,很大程度上是因为它的颜色饱和度高、色块干净。但水墨画恰恰要削弱这种高饱和。直接把饱和度降为0是最懒的做法,画面会变得灰扑扑,缺乏水墨画那种“墨分五色”的层次。

我的做法是这样:先把颜色转换到YCoCg空间或直接用Luminance权重,计算出一个InkValue,然后设定三段式映射:

  • InkValue < 0.25:映射为焦墨(接近纯黑,但保留极少色相)
  • InkValue 在0.25到0.75之间:映射为淡墨(灰度平滑过渡,稍微压暗)
  • InkValue > 0.75:映射为留白(直接输出背景纸色,通常带一点点暖灰或米白)

为了让画面更“画意”,我还会根据原始颜色做一个低饱和的色相偏移:偏绿的草木稍微往青色调,偏土色的岩石往赭石色调,偏天空的蓝色往灰青色淡。这一步模拟的是国画颜料“花青+赭石”的设色习惯。用一句代码概括就是finalColor = lerp(originalColor, inkColor, inkStrength),但这里的inkStrength不是全局常量,而是基于亮度、深度和噪声三者的采样结果。

3. 实操过程与核心环节实现

3.1 Shader主体框架:一次Blit完成85%的工作

我最终实现的Shader有4个Pass:第一个Pass做边缘检测和墨色分层(合成主画面),第二个Pass做方向性模糊(模拟笔触晕染),第三个Pass做飞白噪声叠加,第四个Pass做最后的颜色校正与留白边缘软化。

先看主Shader的Properties部分:

Properties { _MainTex ("Screen Texture", 2D) = "white" {} _DepthTex ("Depth Texture", 2D) = "white" {} _PaperColor ("Paper Color", Color) = (0.95, 0.92, 0.85, 1) _InkDark ("Ink Dark Color", Color) = (0.08, 0.08, 0.1, 1) _InkMid ("Ink Middle Color", Color) = (0.35, 0.35, 0.38, 1) _EdgeThreshold ("Edge Threshold", Range(0.0, 1.5)) = 0.5 _NormalEdgePower ("Normal Edge Power", Range(0.0, 3.0)) = 1.2 _NoiseScale ("Dry Brush Noise Scale", Range(0.0, 0.5)) = 0.15 }

片元着色器里,我会采样深度纹理和法线纹理,计算边缘强度:

float depth = LinearEyeDepth(SAMPLE_TEXTURE2D(_DepthTex, sampler_ScreenTextures, uv).r); float3 normal = DecodeNormal(SAMPLE_TEXTURE2D(_CameraNormalsTex, sampler_ScreenTextures, uv)); float depthEdge = length(float2( depth - LinearEyeDepth(SAMPLE_TEXTURE2D(_DepthTex, sampler_ScreenTextures, uv + float2(_MainTex_TexelSize.x, 0)).r), depth - LinearEyeDepth(SAMPLE_TEXTURE2D(_DepthTex, sampler_ScreenTextures, uv + float2(0, _MainTex_TexelSize.y)).r) )); float normalEdge = length(float2( length(normal - DecodeNormal(SAMPLE_TEXTURE2D(_CameraNormalsTex, sampler_ScreenTextures, uv + float2(_MainTex_TexelSize.x, 0)))), length(normal - DecodeNormal(SAMPLE_TEXTURE2D(_CameraNormalsTex, sampler_ScreenTextures, uv + float2(0, _MainTex_TexelSize.y)))) ));

这里深度用的是LinearEyeDepth,比较的是实际距离差,单位是米,阈值才具备场景语义。如果直接用uv空间深度比,近处的边缘会过强,远处的边缘基本消失。

3.2 方向性模糊:模拟毛笔的笔触走向

普通的高斯模糊会让画面像蒙了一层雾,不符合水墨画“笔触有方向”的感觉。我在第二个Pass里做了拉长的椭圆高斯核,方向和屏幕空间的梯度方向垂直,也就是说沿着边缘的方向模糊,垂直边缘的方向保留。这样墨色会顺着轮廓“渗开”,而不是均匀向四周晕。

模糊核的实现可以用两个Pass的分离模糊代替二维卷积。先横向模糊,再纵向模糊,但两次模糊的sigma不一样,横向大、纵向小,再配合一个旋转角度。运行期每帧动态旋转这个角度会带来画面抖动,我建议固定在45度方向,因为传统国画的皴法多用斜向笔触,45度最自然。

3.3 飞白与墨迹随机性:让每一帧都不一样

水墨画最迷人的地方在于偶然性,而3D渲染最讨厌的就是偶发性闪烁。这两者之间的调和需要技巧。我用了一张Perlin噪声纹理,在片元着色器里基于屏幕坐标和噪声强度,对边缘的绘制概率做减法:

float noise = tex2D(_NoiseTex, uv * _NoiseScale + _Time.y * 0.03).r; float dryBrush = smoothstep(0.4, 0.9, noise); edgeIntensity = edgeIntensity * (1.0 - dryBrush * _DryBrushAmount);

这里的时间项如果太快,画面会出现明显的“爬动感”,看起来很脏。我的经验是_Time.y的系数控制在0.02到0.05之间,让人感觉到墨迹在微微呼吸,而不是在闪烁。如果你需要完全静态的截图,可以把时间系数设为0,只用空间噪声,画面依然有飞白效果。

3.4 深度雾化为留白:让远处淡出纸面

水墨里远处的山不是被雾霾吞掉的,而是直接“没有画完”,留白给你想象。这一版我用深度信息做了一个非线性远近映射。具体做法是取相机深度纹理,设定一个前景距离和背景距离,两者之间做Smoothstep,得到fadeAmount,然后将这个因子乘到墨色强度上:

float fade = 1.0 - smoothstep(_NearFadeDistance, _FarFadeDistance, depth); inkColor = lerp(_PaperColor, inkColor, fade);

这样离相机近的物体墨色重,远的物体墨色轻,直到完全融进纸色。这个参数必须根据场景尺寸调节,我这边场景的单位是米,_NearFadeDistance设为8米,_FarFadeDistance设为60米,正好保证中景的石头还保留清晰轮廓,而背景的山体只有若隐若现的线条。

4. 常见问题与排查技巧实录

4.1 问题一:编辑器中效果好,打包后边缘闪烁

这是我最先遇到也最头疼的问题。编辑器里怎么调都正常,打包成Windows独立版后边缘出现大量白边和闪烁。排查了半天,最终发现是深度纹理精度不一致。编辑器默认使用32位深度缓冲,而某些打包配置下会自动降为24位或16位。近距离的边缘提取对深度精度极其敏感,深度稍微跳动,边缘强度就剧烈变化。

解决办法是强制在GraphicsSettings里关闭深度压缩,或者在Shader里对深度值做一次平滑:

float depthSmoothed = depth; for (int i = -2; i <= 2; i++) { depthSmoothed += LinearEyeDepth(SAMPLE_TEXTURE2D(_DepthTex, sampler_ScreenTextures, uv + float2(_MainTex_TexelSize.x * i, 0)).r); } depthSmoothed /= 5.0;

做一次5次采样的水平平滑后,闪烁肉眼不可见,代价是边缘会稍微变粗,把边缘阈值调高0.1即可抵消。

4.2 问题二:Low Poly的棱边全部变成了黑色粗线

这个问题特别典型。Low Poly模型因为面数低,棱边处法线变化大,深度值在同一条棱上也有明显跳变。当深度边缘阈值和法线边缘阈值同时作用时,棱边会被叠加两次,结果就是粗细不均的黑色线条,画面瞬间从“水墨”变成“炭笔画”。

解决策略是:

  • 降低法线边缘的权重,只让深度边缘做主轮廓。
  • 在提取法线边缘之前,先对深度做一次median采样,过滤掉深度噪声。
  • 使用edgeIntensity = max(depthEdge, normalEdge * 0.6)而不是相加。

实测下来,Low Poly模型用max合并边缘,比sum更干净,因为不会出现两条边缘重叠导致的黑线。

4.3 问题三:画面整体偏灰,缺少“墨分五色”的对比

后处理只能基于已有画面对比度做调整,如果原画面本身灰蒙蒙,后期也救不回来。Low Poly场景通常光照对比强烈,但全局光照不够,容易出现暗部死黑、亮部死白的情况。

我在水墨Shader里额外加了一步选择性对比度:

float luma = dot(color.rgb, float3(0.299, 0.587, 0.114)); float contrastFactor = 1.2 + (1.0 - luma) * 0.4; color.rgb = lerp(_PaperColor, color.rgb, contrastFactor);

这一步的意思是:暗部区域(低Luma)对比度增强,亮部区域对比度轻微下降,从而让暗部更“沉”,亮部更接近纸色。如果你发现画面仍然发灰,可以检查场景里是否开启了环境光遮蔽,适当的AO能显著增加水墨画的体积感。

4.4 问题四:后处理执行顺序导致的色偏

后处理之间是有顺序的。如果项目里同时使用了Color Grading、Bloom或Vignette,水墨Shader应该放在这些效果之后还是之前,结果截然不同。我建议把水墨Shader放在最后一步,也就是所有颜色调整已经完成之后再执行。这样水墨风格的墨色不会被后续的Color Grading二次篡改,能够稳定输出纸色和墨色。

如果你用的是Post Processing Volume,需要把水墨Shader写成自定义Renderer Feature并挂在Volume的最上层,或者在C#脚本里手动控制Blit的执行顺序。我在这个项目里没有用Post Processing Stack,而是纯粹用OnRenderImage,原因就是想彻底控制顺序,不被URP的默认后处理干扰。

5. 性能优化与参数调优建议

5.1 在真机上的性能表现与瓶颈定位

我的测试环境是PC端GTX 1060,1080p分辨率,全屏后处理整体开销大约在2.3ms。这个耗时对于PC可以接受,如果目标是移动端,就得很认真地优化。

性能瓶颈主要在三处:

  • 方向性模糊的两次全屏采样。
  • 深度法线边缘检测的多次深度采样。
  • 噪声纹理的采样。

移动端我建议把模糊降为单Pass的双边滤波,采样次数从13次降到5次。另外,边缘检测的采样可以降为4次(上下左右四点),放弃对角线采样,画面边缘会稍微粗糙一些,但帧率能稳定在60FPS以上。

5.2 高清分辨率的锯齿问题

到4K分辨率后,画面锯齿主要出现在墨色分层的边界上,那种走一步跳一级的阶梯感非常明显。我在Shader里加了一个dither函数,在墨色分层的阈值处叠加一个微小的伪随机抖动:

float dither = (frac(sin(dot(uv, float2(12.9898, 78.233))) * 43758.5453) - 0.5) / 32.0; float layer = step(threshold + dither, inkValue);

这让分层的边界产生类似抖动的过渡,视觉上不再是生硬的阶梯,而像是墨迹在宣纸边缘的轻微洇开。注意这个dither幅度不能太大,否则会出现噪点感。

5.3 动态调节:把水墨浓淡变成可交互参数

这个效果天然适合做动态调节。我在C#脚本里暴露了几个公开变量:墨色浓度、边缘强度、留白距离、飞白强度。在运行时用Lerp让这些参数随时间变化,可以实现“墨色渐浓/渐淡”的呼吸感。配合UGUI做一个滑动条调整这些参数,观众可以实时体验水墨风格从偏写实到偏写意的变化,演示效果非常好。

具体C#核心代码很简单:

[Range(0f, 2f)] public float inkStrength = 1f; [Range(0f, 1f)] public float dryBrush = 0.3f; [Range(0f, 1f)] public float paperFade = 0.8f; private void OnRenderImage(RenderTexture src, RenderTexture dest) { if (material == null) { Graphics.Blit(src, dest); return; } material.SetFloat("_InkStrength", inkStrength); material.SetFloat("_DryBrushAmount", dryBrush); material.SetFloat("_PaperFadeAmount", paperFade); Graphics.Blit(src, dest, material, 0); }

5.4 参数速查表:一个我试过还算稳的起步配置

参数建议值说明
Edge Threshold0.45深度边缘主阈值,场景大则调高
Normal Edge Power0.6法线边缘辅助权重,不宜高过1.0
Ink Dark Color(0.06, 0.06, 0.08, 1)墨色不宜纯黑,带一点蓝灰更通透
Paper Color(0.94, 0.92, 0.85, 1)米白纸色,不要用纯白
Noise Scale0.12飞白噪声密度,太高会花
Near Fade8近景墨色保持距离,单位米
Far Fade55远景完全融进纸色的距离,单位米
Blur Sigma1.8笔触晕染强度,太大糊成一片

从这套配置出发,微调10分钟基本就能出一个自己满意的效果。先跑通管线,再追求艺术的微妙感受,比一开始就死磕某一个参数要高效得多。

6. 最后的个人实操收获

说实话,这个项目最让我意外的不是Shader本身怎么写,而是后处理风格化真正的生命力来自“信息的选择性丢失”。Low Poly场景提供了非常干净的深度和法线信息,这恰好是水墨边缘提取的绝佳素材。你不需要去搞一堆复杂的BRDF模拟宣纸,只要把人类视觉对水墨画的先验——留白、飞白、墨色分层——翻译成屏幕空间的几行数学运算,效果就已经相当有说服力了。

还有一个技巧想分享给看到这里的人:别一开始就把所有效果全开。先用一个Pass只做边缘提取,把画面调成“白底黑线稿”,确认线稿结构符合预期后再逐步加入墨色分层、模糊和噪声。这样的迭代调试方式能减少大量无效工作,也更容易定位是哪一步破坏了画面。

如果想在这个基础上继续扩展,我可以明确告诉你几个方向:从单层水墨扩展到多层“浅绛山水”设色,叠加一个视差映射模拟宣纸纹理,或者加入角色描边的动态粗细变化。每一步都有不少坑,但也很有意思。希望这篇文章能帮你跳过那些我踩过的坑,早点做出自己满意的水墨效果。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询