☰
手绘质感实时渲染:Unity Shader实现PBR纹理的画笔与画布效果
2026/9/30 5:25:14 网站建设 项目流程

去年接了一个文旅项目的实时渲染片,甲方丢过来一堆文物扫描的PBR贴图,开口就是要“手绘质感的画面”。我第一反应是改饱和、叠滤镜、套个风格化后处理,结果预览出来的东西怎么看都像塑料玩具。后来我才想明白一件事:绘画感从来不是调色能调出来的,它是材质自身对纹理采样方式、对光照交互逻辑的一次重新解释。

后面我把这套逻辑落成了一个自写的Unity Shader材质,核心就干一件事——任何一张普通纹理贴进去,不需要重新绘制,它都会自带笔触、色阶断层和画布肌理。这篇不聊大而全的NPR渲染管线,就专门拆这款材质:它的原理、代码、纹理预处理、坑点,以及调参数时容易翻车的地方。想给写实PBR换个手绘皮肤的话,这套思路直接可以拿去做底子。

1. 先把“绘画感”拆成三个可计算的维度

1.1 为什么照片级PBR纹理放进引擎只剩“塑料感”

先复盘一下最初的失败。常规PBR材质的渲染目标是“物理正确”:BRDF要保证能量守恒,粗糙度、金属度各司其职,纹理从高光到暗部是连续渐变的过渡。这套逻辑非常适合还原真实物体,但它天然和“绘画感”冲突。因为手绘作品里,颜料本质上是在做有限次数的叠加,每一层的边缘都有明确的边界,不可能出现物理世界那种无限细分的光照过渡。你把一张照片级纹理丢进标准PBR Shader里,得到的一定是照片,而不是画。

还有个容易被忽略的点:PBR通道里的金属度、粗糙度要求干净准确,但绘画材质的金属感基本是画出来的,粗糙度变化也倾向于“区域块状”,而不是像素级起伏。很多人在照片上硬叠滤镜,结果高光反射还是物理正确的,画布没有肌理,笔触没有方向,看起来就像“AI手绘风”,假得不行。

1.2 绘画感的三个可计算特征

在动手写Shader前,我把“绘画感”强行拆成了三个能落在数学上的特征,这也是整个材质的三个处理层:

  1. 色阶断层(Posterization):颜料的混合是有限的,所以明暗过渡应该出现阶梯感,而不是平滑渐变。这对应数字图像里的色调分离,也是最容易出“画味”的一步。
  2. 笔触痕迹(Stroke Distortion):画笔会在画布上留下方向性轨迹,颜色不是平铺上去的,而是被“拖”出来的。体现在Shader里就是对UV采样坐标做方向性扰动。
  3. 画布肌理(Canvas Bump):布纹、纸纹的微表面起伏,会让颜料在不平整的介质上产生斑驳的高光和局部的浓淡差异。这一步需要靠法线扰动解决。

只要把这三个维度组合好,不管是照片纹理、材质库纹理,还是程序化生成的纹理,都会瞬间带上一层手绘质感。后面我所有的代码、参数、踩坑记录,都围绕这三条展开。

2. 核心Shader三件套:色阶断层、笔触扰动、画布法线

这套材质我用的是Unity URP下的HLSL写法,实际逻辑在片元着色器里按顺序执行:先采样Albedo颜色,做色阶量化;再基于坐标噪声做笔触扰动;最后把画布法线叠到原本的法线细节上,参与光照。下面拆开讲。

2.1 第一步:在色阶上做“颜料分层”

色调分离的代码非常简单,就是把连续的颜色值映射到有限的梯级上:

float3 QuantizeColor(float3 color, float levels) { float3 quantized = floor(color * levels + 0.5) / levels; return saturate(quantized); }

levels代表单通道分几阶。设成5,就意味着每个颜色通道只有5个梯度,整个画面的明暗过渡会呈现明显的“分层感”,像是用彩铅叠了几次色。设成2,就直接往海报化方向走,颗粒感极重。

但直接这么做会有一个严重问题:大面积純色区域会因为梯度边界产生肉眼可见的色带(banding),尤其暗部特别明显。解决方式不是把梯度调多,而是加抖动(dithering),用随机噪声把梯度边界的硬边“敲碎”,模拟颜料混色时残留的细小颗粒:

float3 DitherColor(float3 color, float levels, float2 uv) { float rand = frac(sin(dot(uv, float2(12.9898, 78.233))) * 43758.5453); float3 band = floor(color * levels) / levels; return saturate(band + (rand - 0.5) / levels); }

这个抖动幅度我建议控制在半个梯级以内,太大会让画面发脏,太小又压不住色带。实际项目里我还会在暗部区域单独调大抖动权重,因为人眼对阴影区域的颜色断层最敏感。

2.2 第二步:用方向性噪声模拟笔触痕迹

这一步是整个材质“有没有绘画感”的分水岭。很多仿手绘效果只做了色调分离,结果画面像动漫滤镜,没有“笔触”那种手工涂抹的味道。

笔触的核心理念是:在采样Albedo之前,先对UV坐标做一个有方向的偏移。偏移量需要来自一个连续噪声,不能是纯白噪音,否则画面只会变脏。

float SimplexNoise(float2 p) { // 这里用你习惯的Simplex/Perlin噪声实现即可 } float2 uv = i.uv; float2 worldPos = i.worldPos.xz; float freq = _StrokeFrequency; // 笔触噪声频率 float noise = SimplexNoise(worldPos * freq); float2 strokeDir = normalize(float2(0.8, 0.35)); // 全局笔触方向向量 float2 uvOffset = strokeDir * (noise * 2.0 - 1.0) * _StrokeStrength; fixed4 albedo = tex2D(_MainTex, TRANSFORM_TEX(uv + uvOffset, _MainTex));

这里有两个细节是常规教程不会写的,但我建议你重点注意:

  • 扰动一定要放在世界坐标空间,而不是UV空间。如果直接用uv做噪声输入,物体一旋转,笔触方向就会跟着转,看起来像整张贴图在水下漂。改用worldPos.xz后,无论模型怎么摆放,笔触方向都保持稳定,这才像画师固定用同一方向的笔法。
  • 噪声频率和UV密度是两个维度。频繁的笔触会碎,低频的长笔触才有“一笔到底”的感觉。默认我习惯把_StrokeFrequency控制在2到6之间,数值再大就成了噪点,不像绘画。

如果你的画面有大量曲面,还可以在这个阶段叠加顶点颜色(Vertex Color)来控制局部笔触强弱。比如脸部区域强手绘感,金属饰品弱化,这个扩展放在后面第6章细说。

2.3 第三步:给表面叠一层“画布法线”

有了色阶和笔触,画面会接近“插画风”,但放在3D场景里旋转视角时,会发现高光反射过于光滑,像喷了清漆的插画,少了画布本身的粗糙感。这一步需要程序化生成布纹法线,叠到原有法线细节上:

float2 CanvasBump(float2 worldPos) { float weaveX = sin(worldPos.x * _CanvasScale) * 0.5 + 0.5; float weaveY = sin(worldPos.y * _CanvasScale) * 0.5 + 0.5; float weave = weaveX * weaveY; return float2(weave - 0.5, weave - 0.5) * _CanvasStrength; } float3 normalTS = UnpackNormal(tex2D(_BumpMap, uv)); float2 canvasBump = CanvasBump(worldPos); float3 finalNormalTS = normalize(float3(normalTS.xy + canvasBump.xy, normalTS.z));

这里借用的是细节法线混合的思路:原法线贴图负责表面的大起伏,布纹噪声只负责在微观层面制造“凹凸不平”的反射扰动。Shader里的布纹法线是程序化生成的,不用额外贴图,带宽友好。

但有个天坑:画布法线强度不要超过0.3。一旦超过,高光区域会像闪光纸一样剧烈闪烁,旋转摄像机时画面全是噪点,第一眼看上去非常“脏”。我踩过这个坑后在材质面板里直接把_CanvasStrength钳制到0到0.3,美术同学想拧都拧不坏。

3. 让“任何纹理”都能直接接入的预处理流程

写好了Shader,接下来就是“任何纹理”这四个字的兑现。理论上拍照纹理直接贴进去也能出效果,但如果导入、压缩、tiling这些环节没管好,绘画感会被硬生生打折。我整理了一套预处理流程,按顺序走一遍基本不会翻车。

3.1 纹理压缩乱设,绘画感直接报废

很多项目默认使用ASTC或BC7压缩,这本身没问题,但压缩质量等级会直接影响色阶断层的表现。本来你的张色阶量化就在人为制造梯度边界,压缩算法再引入块状噪斑,这两者叠加会让暗部区域产生一块一块的“脏色块”,和颜料颗粒完全是两回事。

我的建议是:Albedo这张图在Unity导入设置里手动选择“High Quality”,条件允许的情况下用RGB Compressed BC7或完全不压缩。法线贴图和画布高度图可以走低一档的压缩,因为Step 2和Step 3的噪声扰动本质上会掩盖一部分压缩痕迹,但最终颜色通道的压缩痕迹是藏不住的。

另外,如果你用Substance Painter导出贴图,注意导出路径不要带中文和特殊字符,否则进Unity后经常出现贴图紫色或路径丢失。这个和“SU贴图不显示纹理”是同一类问题,多数时候不是纹理坏了,而是导入阶段路径或压缩格式已经出错。

3.2 重复纹理的接缝与“一眼假”问题

纹理一tiling,画面上就会出现肉眼可辨的重复感。普通PBR里可以通过贴图排布来缓解,但绘画感材质里色阶断层会把重复边界放得更大,因为接缝处往往是色带跳变的最强位置。

处理重复感有两个方向。第一个方向是源头处理:在Blender或Substance Designer里烘焙可平铺贴图,注意烘焙时的镜像边缘以及X/Y方向的交叉重叠,这是Blender重复纹理烘焙的关键步骤,方法网上一搜一大把,关键是烘焙前要把UV边界seam清理干净。

第二个方向是Shader侧处理,适用现有不可平铺纹理。我给Albedo叠加一个低频噪声,打碎重复感:

float tileNoise = tex2D(_NoiseTex, uv * _NoiseTiling).r; albedo.rgb *= 0.9 + 0.1 * tileNoise;

这个技巧的原理是:让相邻tile区域的明暗细微变化,记忆点不再集中在同一个接缝上,人眼就不容易识别出重复单元。注意噪声tiling尽量不要和主贴图tiling成整数倍关系,否则两者周期叠加,反而出现更大的摩尔纹。

3.3 没有法线贴图时,如何凑出画布细节

现实中经常遇到这样的纹理:只有一张漫反射,没有配套法线。大部分人的做法是放弃法线细节,直接让表面光洁如镜——对绘画感材质来说这不可接受。

解法是用亮度信息生成虚拟法线。对采样得到的灰阶高度图,求相邻像素差,就能得到近似的法线方向:

float h = tex2D(_MainTex, uv).r; float hL = tex2D(_MainTex, uv + float2(_MainTex_TexelSize.x, 0)).r; float hD = tex2D(_MainTex, uv + float2(0, _MainTex_TexelSize.y)).r; float2 grad = float2(hL - h, hD - h) * _BumpFromAlbedo; float3 normalTS = normalize(float3(-grad.x, -grad.y, 1.0));

这个办法在近距离或高频细节上表现一般,但对绘画感材质的画布肌理层面足够用了。代价是比普通材质多了两次纹理采样,移动端建议离线烘焙一张高度图,不要用动态求导。

3.4 顺带处理一下“贴图不显示”的乌龙

热词里连续出现“SU贴图不显示纹理”“UG表面材质渲染”,我估计不少人是在这里卡住了。实话说,这类问题90%不是纹理本身坏,而是三件事没检查:一是UV展开丢失,模型全挤在一个像素;二是材质球通道没接对,Shader里取了_BumpMap,材质球上却把法线贴图拖进了Albedo槽;三是导入设置把纹理类型设置成了Sprite或Single Channel,导致采样出来是黑的。

处理顺序建议是:先在材质球上确认Shader匹配的通道,再切到场景视图看模型线框,确认UV分布符合预期,最后检查导入面板的Texture Type和压缩格式。把这个顺序走完,大概能覆盖绝大多数“纹理不显示”的问题。

4. 双面材质、视差替身与“纹理内部/像素内部”的解惑

到这里,材质已经能出不错的“画感”。但真正把它放进实际项目,还要处理几个让新手头大的概念:双面渲染、视差贴图,以及最近被反复讨论的“纹理内部和像素内部的区别”。

4.1 双面材质的法线翻转陷阱

绘画感材质常被用在纸片式模型、树叶、旗子这类薄片几何上,需要双面可见。只把Unity的Cull Mode设为Off是不够的——背面虽然渲染了,但顶点顺序反了,法线会指向几何内部,光照结果完全错误,轻则黑面,重则高光乱跳。

正确的做法是在Shader里手动做一次朝向调整:

float facing = dot(normalWS, viewDirWS); float3 finalNormalWS = facing > 0 ? normalWS : -normalWS;

这样正面和背面的法线都能保证朝向摄像机一侧参与光照计算。当然,开启双面会带来Overdraw翻倍,如果你在同一片面上又叠加了视差映射,绘制开销会明显上涨,这两个功能通常不建议同时打开。

4.2 用视差映射做出“颜料堆积”的立体感

绘画不是平涂,颜料在画布上是有厚度的。尤其是油画风格,颜料堆积处的光影偏移感很强。简单法线扰动模拟不了这种“高度错位”,需要上视差映射(Parallax Mapping)。

最实用的轻量实现是单层视差偏移采样:

float2 ParallaxOffset(float2 uv, float3 viewDirTS, float heightScale) { float depth = tex2D(_HeightMap, uv).r; return uv - viewDirTS.xy / viewDirTS.z * depth * heightScale; }

_HeightMap就是一张灰度高度图,拿Albedo转的灰度也行。viewDirTS要放在切线空间计算,不然视角方向在曲面上的投影会错位,笔触厚度看起来像在滑动。

标准的视差遮蔽映射(POM)会在循环里分层采样,效果更好但开销高好几倍。我在这套材质里只用单层,因为绘画感的视觉本身偏概括,颗粒太大反而不像画。高度缩放控制在0.02到0.05之间,别超过0.1,否则边缘位移会穿帮。

4.3 纹理内部和像素内部到底差在哪

这是最近群里讨论很多的一个概念。我的理解是这样的:“纹理内部”指纹理坐标所代表的连续空间信息,包括texel之间插值、Mipmap层级选择、UV方向和缩放;“像素内部”指单颗屏幕像素上的颜色与亮度值,是离散的、非连续的。

绘画感的扰动必须发生在“纹理内部”这个空间,而不是“像素内部”。用前面笔触扰动的例子说:对UV坐标加一个方向性偏移,让采样点顺着画笔方向流动,这是纹理空间内部的连续形变。如果你换成对像素颜色做随机抖动,得到的不是笔触,是电视雪花;而且一旦Mipmap介入,抖动模式会被平均掉,画面在远处会变成均匀的灰雾。

同理,色阶量化虽然作用于像素颜色,但它的“阶数”是在纹理内部定义好的连续梯级,像素只是映射到这个梯级上。理解这个区别能帮你少走弯路:一切物理上的“流动”“拖拽”“堆积”效果,先在纹理坐标上找解法;一切“颗粒”“噪点”“闪烁”效果,才轮到像素层面。这套材质里,笔触属于前者,抖动属于后者。

5. 参数调优、性能压榨与常见翻车现场

Shader写完只是第一步,材质要出效果靠的是反复调参。我把这套材质最终沉淀成了几个关键参数,美术在Inspector里拧起来很直观。下面给出一份速查表,以及我压性能时常用的三板斧。

5.1 关键参数速查表与默认值

参数默认值推荐范围作用
_PaletteLevels色阶数52~12控制色调分离强度,越低越接近海报化
_DitherAmount抖动强度0.50~1压色带,太大会让暗部发脏
_StrokeStrength笔触强度0.030~0.1控制UV偏移量,太大画面糊成一片
_StrokeFrequency笔触频率41~20控制笔触疏密,越高越碎
_CanvasScale布纹密度4020~80画布纹理的网格密度,过高会闪
_CanvasStrength布纹法线强度0.150~0.3法线扰动强度,大于0.3高光开始爆炸
_ParallaxScale视差厚度0.020~0.05颜料堆积的高度感

我个人的默认组合是:色阶5、抖动0.5、笔触强度0.03、频率3、布纹强度0.15。这个组合在大多数场景都能稳定输出“有画味但不脏”的结果。美术如果觉得太淡,先动笔触强度,再动色阶数;如果觉得太油腻,把布纹法线强度降下来,通常能救回来。

5.2 片元着色器性能优化三板斧

这套材质的基础开销其实很低,但如果没管住手,一个片元里可能会塞进五六次纹理采样,移动端直接完蛋。我压性能习惯用这三招:

  1. 噪声全部程序化。笔触扰动里的Simplex噪声、画布法线的正弦函数都是纯数学运算,不占带宽。别去采样噪声贴图,看似省了GPU计算,实际上多一个纹理取指和mip选择,开销更高。
  2. 视差单层到底。不做多层视差遮蔽循环。真要更好的效果,就把高度图预处理成法线传递,用Step 2的扰动替代,视觉收益接近,性能差距巨大。
  3. 避免dFdx/dFdy做动态求导。前面第3章用亮度求法线的方案,如果写成fwidth(dot(...))会高效很多,但部分移动端GPU对屏幕空间导数支持不佳,会出现条纹状闪烁。稳妥做法是离线烘焙好高度图,运行时只采样一次。

另外,如果工程用的是URP,写材质时注意尽量走单Pass。开双面之后Overdraw翻倍,视差再叠进去,移动端掉帧是必然的。

5.3 几个高频翻车现象的完整排查链路

我在这套材质的迭代过程中攒了一堆问题记录,挑几个最有代表性的列出来。这里直接给排查链路,不绕弯子。

现象根因修复方法
大面积色带横向/竖向条纹色阶量化被纹理压缩噪斑放大提高Albedo导入质量,适度加强抖动
笔触方向随着模型旋转而旋转噪声采样用了UV坐标改成世界坐标,控制向量保持全局统一
摄像机一动高光像雪花闪烁画布法线强度过大压到0.3以下,必要时降低布纹密度
双面渲染后背面全黑法线未翻转用facing > 0 ? normalWS : -normalWS
远处看纹理糊成灰团笔触扰动没有随Mip变化把噪声解耦出Mip之外,或用世界坐标偏移采样
贴图显示不出来/紫色纹理路径、压缩格式、通道接错按3.4节的流程逐项检查

排查的时候记得一个原则:一次只动一个变量。很多翻车是多个因素叠加出来的,同时调三个参数会让人完全失去对照依据。我自己刚开始调这材质时,一晚上改了几十版参数,最后都不知道是哪个改动让画面变好,后来老老实实一个个变量调,效率反而高得多。

6. 这套材质能用到哪,以及后面还能怎么玩

这套材质做好之后,我手里几个项目都在用,但并不是所有场景都适合。这里把它的能力边界说清楚,免得你堆上去被美术同事骂。

6.1 适合的场景与反例

最适合的是这几类:

  • 数字展厅/文博漫游:文物扫描件套上这套材质,色阶断层直接把照片级贴图“翻译”成插画,配合暖光氛围很容易出视觉记忆点。
  • 风格化游戏道具:原本从真实物体扫描的纹理不用重新绘制,一次导入直接变手绘风,大大缩短资产制作周期。
  • 动画电影Preview:灯光预演阶段用这套材质快速模拟美术定调的“绘画感”,比纯白模直观得多。

不太适合的场景也有:需要真实材质俯视感的电商产品模型,凭直觉是不可能跑到这套材质里来的。

6.2 扩展玩法:顶点笔刷、顶点色流向与Substance预设

最后分享几个我在这套材质基础上做的扩展。第一个是顶点着色器层面的笔刷位移:读取顶点色R通道,在顶点偏移阶段直接按笔触方向做一个微小位移,这样不仅颜色有笔触感,几何轮廓本身也带手绘的“毛边”。配合法线扰动,效果非常绝。

第二个是顶点色控制笔触方向。默认的strokeDir是全局向量,但不同区域的笔触方向应该跟着形体走,比如圆柱侧面是环绕的,圆柱顶面是放射的,全局向量完全不适用。把顶点色存成方向角度,在片元里map到向量,材质就拥有了局部可控的笔触流向,这是进阶玩法里我优先推荐你尝试的。

第三个其实是工作流技巧:把这套Shader在Substance Painter里做成一个智能材质滤镜,烘焙出来的Chanels直接匹配Shader的属性。这样平时做新资产时,烘焙完贴图直接用这个材质球,出图风格始终统一,不需要每次重新调Shading Model。

我之前断断续续调了三周,最大的领悟是:这类风格化材质的难点往往不在Shader本身,而是“松手”。不要追求每个像素都符合物理直觉,把参数留出来交给美术去试,核心算法稳定即可。现在的习惯是默认值全部取中庸,进场景后根据灯光、距离和氛围再拧细节。这套材质你拿去做底子,在此基础上继续叠滤镜、叠后处理,潜力还大得很。

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

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

立即咨询