
1. 为什么Low Poly加后处理能变成水墨画视觉特征的匹配逻辑1.1 Low Poly场景的先天优势我在做中国风Demo的时候一开始想得很简单找一批水墨风格模型或者给Low Poly模型换一套水墨贴图。结果折腾了几天改出的效果又脏又乱山不像山树不像树。后来我把思路反过来不再去改模型和材质而是直接在屏幕空间做后处理反而两天就出了效果。这件事让我意识到一个关键点Low Poly本身就是水墨风格的天然输入只是大多数人没有从渲染管线的角度去看它。为什么这么说你仔细拆一下Low Poly场景的特征面片数量少、颜色过渡基本靠flat shading、物体轮廓和内部转折棱角非常清晰、几乎没有逐像素细节。这些东西放在写实渲染里是弱点但放在水墨画里恰好对应了水墨最核心的勾线和皴擦。山的轮廓线、岩石的转折面、房屋的屋檐边缘这些在Low Poly里都是几何上硬切出来的特征用后处理去提取时极其干净不像高模扫描数据那样充满杂讯。再看水墨画的表现逻辑勾线定形墨色分面留白透气纸纹收底。Low Poly几何提供了明确的形flat shading提供了干净均匀的面所以接下来要做的其实就是一个翻译工作——把几何边缘翻译成墨线把面片亮度翻译成墨色层次把空白区域翻译成宣纸留白。1.2 水墨画风格的五个核心视觉元素要做成水墨效果首先要建立目标参照。我把水墨风格拆成了五个可以在Shader里量化的视觉元素墨线画面中重要结构边缘的深色线条对应水墨中的勾勒特点是边缘存在但不规则不是死板的黑线。墨色层次灰度上分成焦、浓、重、淡、清若干等级对应水墨中的墨分五色本质上就是亮度的离散化分层。晕染墨水在宣纸上向外扩散产生的过渡带视觉上表现为墨色边缘的不规则渐变。留白亮部大量保留纸张本身的颜色不允许被噪点和杂色填满画面要有呼吸感。纸张纹理宣纸纤维造成的细微噪点让大片墨色区域不显得塑料有材质感。五个元素里墨线、墨色层次这两项必须优先做因为它们是水墨的骨架晕染、留白、纸张纹理属于润色能让效果从卡通风格化升级为真正像画。这里有一个重要的工程判断用后处理而不是改材质是因为这些特征全部在屏幕空间表现得最明显。相机转动时山体和山体之间的前后遮挡关系、树冠和树干之间的穿插关系只有在深度和法线层面才能稳定提取单个模型的材质看不全这些信息。2. 搭建后处理框架OnRenderImage与材质参数的对应关系2.1 从相机到屏幕的一趟完整旅程先搭架子。我用的是内置渲染管线所以直接挂OnRenderImage脚本。如果你用的是URP思路完全一样只是要把入口换成RenderFeature的Execute方法后面我会单独说差异。脚本本身不复杂但有几个细节容易被新手忽略using UnityEngine; [ExecuteInEditMode] [RequireComponent(typeof(Camera))] public class InkPaintPostEffect : MonoBehaviour { public Material inkMaterial; public Texture2D paperTexture; public Texture2D noiseTexture; [Range(0f, 1f)] public float edgeThreshold 0.25f; [Range(0f, 1f)] public float edgeIntensity 0.75f; [Range(0.1f, 2f)] public float edgeRoughness 0.6f; [Range(2, 8)] public int inkLevels 4; [Range(0.2f, 2f)] public float contrastBias 0.8f; [Range(0f, 1f)] public float paperAmount 0.35f; [Range(0f, 1f)] public float bleedAmount 0.25f; private void OnEnable() { GetComponentCamera().depthTextureMode | DepthTextureMode.Depth | DepthTextureMode.DepthNormals; } private void OnRenderImage(RenderTexture src, RenderTexture dest) { if (inkMaterial null) { Graphics.Blit(src, dest); return; } inkMaterial.SetFloat(_EdgeThreshold, edgeThreshold); inkMaterial.SetFloat(_EdgeIntensity, edgeIntensity); inkMaterial.SetFloat(_EdgeRoughness, edgeRoughness); inkMaterial.SetInt(_InkLevels, Mathf.Max(2, inkLevels)); inkMaterial.SetFloat(_ContrastBias, contrastBias); inkMaterial.SetFloat(_PaperAmount, paperAmount); inkMaterial.SetFloat(_BleedAmount, bleedAmount); if (paperTexture ! null) inkMaterial.SetTexture(_PaperTex, paperTexture); if (noiseTexture ! null) inkMaterial.SetTexture(_NoiseTex, noiseTexture); Graphics.Blit(src, dest, inkMaterial); } }这段脚本做的事情很少但每一行都有意义。depthTextureMode必须按位或上Depth和DepthNormals否则Shader里采样_CameraDepthTexture和_CameraDepthNormalsTexture会得到一张黑图这是初学者最容易踩的坑。用ExecuteInEditMode是因为调参时希望编辑器里也实时刷新效果不然每调一次参数都要进Play模式看效率极低。参数和Shader里Properties的名称保持一致用SetFloat或SetInt推送到材质里。这里我特意把inkLevels设计成int类型因为后期可能要做按键切换墨色丰富度int参数在代码里更直观。2.2 Shader骨架一个单Pass完成墨线与墨色Shader端我用单Pass把所有步骤放在fragment函数里依次执行。单Pass的好处是不用处理中间RenderTexture的分配和释放跑在移动端上也好控制开销。Shader Custom/InkPaintPost { Properties { _MainTex (Source, 2D) white {} _PaperTex (Paper, 2D) white {} _NoiseTex (Noise, 2D) gray {} _EdgeColor (Edge Color, Color) (0.08, 0.07, 0.06, 1) _EdgeThreshold (Edge Threshold, Range(0, 1)) 0.25 _EdgeIntensity (Edge Intensity, Range(0, 1)) 0.75 _EdgeRoughness (Edge Roughness, Range(0.1, 2)) 0.6 _InkLevels (Ink Levels, Int) 4 _ContrastBias (Contrast Bias, Range(0.2, 2)) 0.8 _PaperAmount (Paper Amount, Range(0, 1)) 0.35 _BleedAmount (Bleed Amount, Range(0, 1)) 0.25 } SubShader { Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc sampler2D _MainTex; float4 _MainTex_TexelSize; sampler2D _CameraDepthTexture; sampler2D _CameraDepthNormalsTexture; sampler2D _PaperTex; sampler2D _NoiseTex; fixed4 _EdgeColor; float _EdgeThreshold; float _EdgeIntensity; float _EdgeRoughness; float _InkLevels; float _ContrastBias; float _PaperAmount; float _BleedAmount; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert(appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } float3 SampleNormal(float2 uv) { float depth; float3 normal; DecodeDepthNormal(tex2D(_CameraDepthNormalsTexture, uv), depth, normal); return normal; } float SobelDepth(float2 uv, float2 texel) { float tl Linear01Depth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv float2(-texel.x, -texel.y))); float t Linear01Depth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv float2(0, -texel.y))); float tr Linear01Depth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv float2(texel.x, -texel.y))); float l Linear01Depth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv float2(-texel.x, 0))); float r Linear01Depth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv float2(texel.x, 0))); float bl Linear01Depth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv float2(-texel.x, texel.y))); float b Linear01Depth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv float2(0, texel.y))); float br Linear01Depth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv float2(texel.x, texel.y))); float gx -tl - 2.0 * t - tr bl 2.0 * b br; float gy -tl - 2.0 * l - bl tr 2.0 * r br; return abs(gx) abs(gy); } float SobelNormal(float2 uv, float2 texel) { float3 tl SampleNormal(uv float2(-texel.x, -texel.y)); float3 t SampleNormal(uv float2(0, -texel.y)); float3 tr SampleNormal(uv float2(texel.x, -texel.y)); float3 l SampleNormal(uv float2(-texel.x, 0)); float3 r SampleNormal(uv float2(texel.x, 0)); float3 bl SampleNormal(uv float2(-texel.x, texel.y)); float3 b SampleNormal(uv float2(0, texel.y)); float3 br SampleNormal(uv float2(texel.x, texel.y)); float3 gx -tl - 2.0 * t - tr bl 2.0 * b br; float3 gy -tl - 2.0 * l - bl tr 2.0 * r br; return length(gx) length(gy); } float hash(float2 p) { return frac(sin(dot(p, float2(127.1, 311.7))) * 43758.5453); } fixed4 frag(v2f i) : SV_Target { float2 uv i.uv; float4 color tex2D(_MainTex, uv); // 1. 几何边缘 - 墨线强度 float edgeDepth SobelDepth(uv, _MainTex_TexelSize.xy); float edgeNormal SobelNormal(uv, _MainTex_TexelSize.xy); float edgeRaw saturate(edgeDepth * 0.05 edgeNormal * 8.0); float edge smoothstep(_EdgeThreshold, _EdgeThreshold 0.25, edgeRaw); edge * _EdgeIntensity; // 笔触粗糙化让墨线边缘带一点不均匀感 float h hash(floor(uv * _ScreenParams.xy * 0.5)); edge * lerp(1.0, h, _EdgeRoughness * 0.3); // 2. 亮度分层 - 墨色层次 float lum dot(color.rgb, float3(0.299, 0.587, 0.114)); lum saturate((lum - 0.5) * _ContrastBias 0.5); float stepLum floor(lum * _InkLevels) / max(_InkLevels - 1.0, 1.0); float ink 1.0 - stepLum; float3 inkColor lerp(float3(0.85, 0.82, 0.78), float3(0.05, 0.05, 0.06), ink); // 3. 混合墨线 inkColor lerp(inkColor, _EdgeColor.rgb, edge); // 4. 晕染在墨色和纸白之间用噪声做过渡 float noise tex2D(_NoiseTex, uv * 6.0).r; float bleedMask smoothstep(0.35, 0.75, edgeRaw noise * 0.12); inkColor lerp(inkColor, float3(0.95, 0.93, 0.9), bleedMask * _BleedAmount); // 5. 纸张纹理叠加 float paper tex2D(_PaperTex, uv * 5.0).r; inkColor lerp(inkColor, inkColor * (0.8 0.2 * paper), _PaperAmount); return float4(inkColor, color.a); } ENDCG } } Fallback Off }代码里第1步到第5步的顺序是固定的先算出哪里有墨线再算出每个像素该用多浓的墨色然后把墨线叠上去最后用晕染和纸张纹理做修饰。这个顺序不能乱否则晕染会把墨线糊掉或者纸张纹理干扰边缘检测。3. 墨线从哪里来深度与法线边缘检测的工程取舍3.1 Sobel算子检测的到底是什么Sobel的本质是图像梯度计算用两个3x3卷积核分别卷积出x方向和y方向的梯度然后合成一个梯度强度值。对后处理来说我不关心像素颜色本身的梯度因为颜色梯度在Low Poly场景里往往出现在面片的颜色边界上容易产生碎线。我更关心几何属性的梯度深度值的突跳代表物体前后遮挡关系法线方向的突变代表模型表面的转折棱角。深度梯度的物理意义很直观当两个物体一个在近处一个在远处但屏幕上相邻时深度值会剧烈变化Sobel值就大。法线梯度的物理意义稍微抽象一点在平坦表面上相邻像素的法线方向几乎一致梯度接近0遇到棱边比如石头的一个棱或者屋檐的转角法线就会突然偏转梯度瞬间拉高。两种信息各有盲区所以代码里我同时保留两路Sobel结果再相加。只做深度检测像山体表面那种连续但带皱褶的地形会出现大片漏检只做法线检测像前后遮挡但没有明显转折的边缘又会丢失。3.2 为什么不同时使用颜色梯度有人会问直接在原始画面颜色上做Sobel不是更快吗确实更快但效果很难受。Low Poly场景里同一个坡面的颜色是均匀的颜色梯度几乎为0等于没检测。而跨面片的颜色跳变往往不是结构边缘而是美术摆上去的色块分界在水墨画里不应该形成墨线。对Low Poly来说几何信息远比颜色信息可靠。不过有一个例外如果场景里有大片水面或天空这些地方深度和法线变化很小墨线会彻底消失视觉上显得空。我的处理办法是把颜色梯度加到edgeRaw里但权重压得很低float edgeColor 0.0; if (unity_LightmapST.z 0) // 示例条件真实项目可以按需要开关 { // 可选的轻量颜色梯度 }实操中我建议把颜色梯度做成可开关的选项默认关闭。因为水面、天空这类区域的墨线缺失比被碎线干扰更容易处理——你可以用手工摆放的模型或粒子来补充画面元素而不是靠边缘检测硬画。3.3 做旧做残让墨线产生勾皴的细节处理纯Sobel得到的边缘线太规整、太平均像CAD线框图不像毛笔画的。毛笔线条的特点是同一根轮廓线有的地方墨浓一点有的地方墨淡一点偶尔还有断笔。我塞了一个edgeRoughness参数来控制这个效果。实现上用的是hash函数取当前屏幕像素坐标的整数部分做哈希得到一个稳定的随机值然后用它给edge强度乘上一个不均匀系数。这里必须用floor(uv * _ScreenParams.xy * 0.5)这样基于像素坐标的哈希不能用纯uv做哈希否则相机移动时整条线会像闪光灯一样乱闪。基于像素坐标的哈希在同一个像素上是时间稳定的画面动起来线条的粗细抖动才不会突兀。_EdgeRoughness值越大线的断续感和粗细变化越明显。我调到0.6左右时山体轮廓看起来已经有手绘感但还不至于碎成噪点线。4. 墨分五色从连续颜色映射到层次化墨色的完整推导4.1 亮度分层公式为什么是floor除以levels减一水墨画不追求面面俱到的明暗过渡它用有限的几个灰度等级来表达体积。我的做法是先把像素转成亮度用Rec.709亮度系数然后做量化float stepLum floor(lum * _InkLevels) / max(_InkLevels - 1.0, 1.0);floor(lum * _InkLevels)会把连续的亮度切成_InkLevels等份。比如_InkLevels 4亮度0.0到0.25映射到00.25到0.5映射到1以此类推。但直接除以_InkLevels会把最大档落在0.75导致最深的一档怎么也到不了纯黑画面显得发灰。所以要除以_InkLevels - 1.0让第一档对应0最后一档对应1这样量化后的亮度范围刚好撑满0到1。这一步做完画面已经具备墨分五色的基础结构平坦面片上的颜色被压缩成同一档墨色相邻面片如果亮度差跨过阈值就会跳成不同的墨色档形成类似皴擦的块面对比。4.2 对比度偏移参数的作用很多Low Poly场景的整体亮度偏高尤其是晴天草地场景亮度分布集中在0.6到1.0之间。直接量化全党全亮什么层次都出不来。所以我在量化之前加了一个对比度偏移lum saturate((lum - 0.5) * _ContrastBias 0.5);_ContrastBias 1.0时相当于不做拉伸小于1会把亮度范围往0.5压缩大于1会把0.5附近的差异放大。实际操作中我发现Low Poly场景通常需要把_ContrastBias调到0.8到1.2之间具体看你希望画面亮一点还是沉稳一点。白天场景想保留更多留白就调到0.8傍晚或者想走浓墨重彩路线就调到1.2以上。这个参数本质上是给量化器做预整形类似音视频编码里的动态范围压缩思路。不先做对比度整形量化器会被极端亮度带偏要么整个画面糊成一团深色要么亮到没有墨色。4.3 墨色色板与Low Poly色彩保留的平衡分层之后我把量化亮度反向映射成墨色float ink 1.0 - stepLum; float3 inkColor lerp(float3(0.85, 0.82, 0.78), float3(0.05, 0.05, 0.06), ink);亮部映射到偏暖的白0.85, 0.82, 0.78接近宣纸的颜色暗部映射到极深的中性灰0.05, 0.05, 0.06接近墨块的颜色。这里的用意是最暗的地方不能是纯黑0纯黑在显示器上会显得死板带一点冷调或暖调才像墨。如果你想保留Low Poly本身的色彩氛围可以在墨色基础上混入少量原始颜色inkColor lerp(inkColor, color.rgb * inkColor * 2.0, _ColorKeep);新加一个_ColorKeep参数0.15到0.3之间能做出淡彩水墨效果山的颜色能透出来一点但又不破坏墨色分层结构。我在Demo里把这个值设在0.2远处山脉保留淡淡的蓝灰色近处树木透出一点绿色整体仍然是中国画的味道。5. 纸张与晕染让画面从卡通升级为水墨的最后两步5.1 纸张纹理的叠加方式纸张纹理不能直接乘法混合直接乘会让亮部变成灰蒙蒙一片尤其留白区域会特别显脏。正确做法是先算出一个纸张明暗系数再在原始墨色基础上做小幅扰动float paper tex2D(_PaperTex, uv * 5.0).r; inkColor lerp(inkColor, inkColor * (0.8 0.2 * paper), _PaperAmount);_PaperAmount控制在0.3到0.4之间效果是墨色区域有细微的纤维噪点而亮部依然保持干净的白。uv * 5.0是把纸张纹理平铺5次避免单个纹理的图案感太强。真宣纸的纤维分布没有明显重复周期但平铺纹理在视觉上只要足够小人眼不会感知到重复。如果你有扫描的宣纸贴图处理成灰度、对比度不要太强的图效果会比程序生成的噪声好很多。注意纹理Wrap Mode要设成Repeat否则平铺时边缘会出现接缝线。5.2 噪声扰动模拟晕染与扩散晕染是水墨区别于其他风格化渲染的关键。水墨在宣纸上不是像素级锐利的墨会顺着纸纤维慢慢向外洇开形成一个不规则过渡带。我用了一种很省性能的近似方法用噪声遮罩在墨色和纸白之间做插值。float noise tex2D(_NoiseTex, uv * 6.0).r; float bleedMask smoothstep(0.35, 0.75, edgeRaw noise * 0.12); inkColor lerp(inkColor, float3(0.95, 0.93, 0.9), bleedMask * _BleedAmount);edgeRaw已经包含了深度和法线的梯度值在边缘附近数值高我在它上面加了一点噪声让边缘附近的过渡带呈现出不规则的扩散边界。_BleedAmount控制晕染强度0.2到0.3之间比较自然调太大会让整个画面糊掉。严格的水墨晕染其实需要做局部拉普拉斯扩散或者基于物理的流体模拟但那种方案在实时渲染里根本跑不动。这种噪声遮罩的近似方法在静态截图和普通相机运动下效果足够好而且性能开销只有一次纹理采样。5.3 留白的处理技巧留白是水墨审美的核心也是最容易被技术方案毁掉的部分。我见过很多风格化后处理为了追求质感给全画面铺噪音结果本该透气的天空、水面全部变成一团灰麻布。我的做法是在量化之前单独给亮部做一个抬升曲线lum pow(lum, 0.9); // 可选亮部稍微提亮别用太强的曲线pow指数0.85到0.95之间即可。这一步的目的是让大面积亮色区域更靠近纸张白而不是让它们带着原场景的颜色残留。配合前面的contrastBias你可以把画面的明暗节奏调整到该留白的地方干净该着墨的地方浓郁。如果场景里有水面我建议额外加一个遮罩逻辑深度特别远、法线朝上的像素可以强制往纸白色靠拢。不过这个要看项目需求不是通用做法我在地形类场景里打开在城市或室内场景就关掉。6. 实战调试记录参数表、闪烁问题与性能优化6.1 一套可直接使用的参数组合我拿一个典型的Low Poly山谷场景调了一组参数效果在PC和移动端模拟器上都验证过你可以作为起点参数数值说明edgeThreshold0.25墨线灵敏度太高会漏线edgeIntensity0.75墨线浓度配合EdgeColor调整edgeRoughness0.55线条手绘感0.3以下偏工整inkLevels4墨色层次想更细腻就设为5或6contrastBias0.9山谷日照场景偏亮压一点更稳重paperAmount0.35纸张纹理强度bleedAmount0.22晕染程度colorKeep0.2淡彩保留比例需代码启用这组参数下近景岩石棱角清晰中景树木有笔触感远景山体因为深度梯度小墨线自动变淡正好形成了空气透视的效果。这个自动的远景虚化我一开始没想到后来发现其实是深度Sobel对远距离物体的天然响应算是意外收获。6.2 画面闪烁与边缘抖动排查调试过程中最容易遇到的两个问题一个是边缘线条在相机移动时抖动另一个是整个画面出现高频闪烁。边缘抖动通常源自深度精度不足。_CameraDepthTexture是16位浮点深度远处物体的深度差异会被压缩到很小的数值区间Sobel计算出的梯度在远处会被量化噪声污染。解决思路有三个把edgeThreshold适当调高抑制弱梯度。对深度Sobel的结果做一次abs之后的截断比如min(edgeDepth, 0.1)避免极远距离的巨大梯度。在脚本里Camera.depthTextureMode保持开启但不要在低精度贴图下强上高质量墨线接受远景线条弱化反而是水墨风格的优势。高频闪烁多半是后处理与抗锯齿的顺序问题。内置渲染管线里OnRenderImage参与的顺序受ImageEffects和相机的HDR设置影响。我遇到的情况是开启MSAA加HDR时后处理在Blit过程中丢了深度信息导致边缘检测结果随机跳动。解决办法是把相机Allow MSAA关掉改成在后处理之后用FXAA或TAA收尾。如果你用的是URP同样建议在后处理Pass之后再做一次抗锯齿。还有一个坑是OnRenderImage在编辑器Scene视图不生效只有Game视图能看到效果。调参时建议把Game视图放大到全屏并且锁定分辨率否则参数参考意义不大。6.3 移动端性能优化建议水墨后处理的成本主要集中在Sobel的纹理采样上。深度Sobel需要8次采样法线Sobel也需要8次再加上主纹理、纸张、噪声单像素总共超过18次采样。桌面显卡无所谓但手机GPU扛不住全分辨率跑。我在移动端做了三件事把后处理渲染分辨率降到屏幕的75%在脚本里声明一个renderScale 0.75fBlit前用临时RT缩小再处理。水墨效果对锐度要求不高降分辨率后的模糊感甚至让墨色更柔和。法线Sobel只保留上下左右4个方向采样不做对角采样。对角信息确实会丢失一点细节但墨线本来就是粗犷风格少两个方向的梯度不太看得出来。关闭edgeRoughness的像素哈希改用简单的sin扰动。差别只在静态帧动态画面下几乎没人注意到。做好这三点我的测试机从8ms耗时降到了3.8ms左右在中端手机上可以流畅跑。如果你要做URP版本把OnRenderImage换成ScriptableRenderPass的Execute方法核心Shader逻辑一分不用改唯一要注意的是URP里深度法线纹理需要在DepthTextureMode里显式声明并且用_CameraNormalsTexture访问法线。最后分享一个我踩过好几次的坑调参时一定要把edgeThreshold和inkLevels分开看。这两个参数一个控制哪里画线一个控制墨色怎么分相互影响很大。很多人调不出效果是因为同时动了好几个参数最后不知道是谁起的作用。我习惯一次只动一个记录下每个参数在画面上的边界表现再根据项目需要反向匹配参数。这套方法听起来慢但实际用起来反而更省时间。