手头那本Shader入门教材翻到第35章标题是素描风格渲染。看到这个标题的时候我心里第一反应是这也能渲毕竟前面的卡通渲染、轮廓光大多是在颜色和光照上做文章而素描这种效果本质上不是颜色的变化而是线条疏密的变化。等把这一章读完我发现它的核心思路非常朴素把光照强度当成一个索引去采样一组预先做好的铅笔线条纹理。灯越暗线条越密灯越亮线条越稀疏。这个思路一下就把风格化渲染的门槛拉低了很多。这篇文章就是我整理这一章的读书笔记内容包括原理拆解、完整Shader实现、调参经验以及我实际跑代码时踩过的几个坑。适合想做NPR风格化渲染的同学参考也适合对光照与纹理映射关系感兴趣的人。1. 为什么是素描NPR里的手绘感1.1 先分清几种非真实感风格非真实感渲染NPR是一个很大的筐卡通渲染、水彩、油画、素描全都装在里面。卡通渲染的思路最常见它把漫反射结果分成两三个色阶配合描边和色块化得到三渲二的效果水彩风格会叠加纸纹和颜色浸润让边缘有晕开的感觉而素描风格走的是另一条路——它不模拟颜料而是模拟人画画的过程。人画素描时想要表达明暗靠的不是涂均匀的灰而是铅笔线条一撮一撮地排上去。排得密看起来就深排得稀看起来就浅。这就是素描渲染最根本的出发点。顺着这个思路光照计算的结果不再直接变成颜色而是变成“这一片区域该画多少线条”的指示。为了把指示落到像素上我们需要一组预制纹理同样的铅笔笔触但线条密度从稀疏到密集好比一支铅笔从轻轻扫过到反复涂画。渲染时光照越强就采样密度越低的纹理光照越弱就采样密度越高的纹理。这样在模型表面能看到很真实的铅笔排线感而不是简单的灰色块。1.2 素描渲染要解决的两个核心问题任何一个素描风格的Shader脑子里都要装着两个问题。第一个问题光照怎么映射到线条密度。这里需要把漫反射结果通常是一个0到1的标量映射到纹理层级的索引上。0到1区间被划分成若干档每一档对应一种线条密度。这本质上是在做量化但量化要处理得足够平滑否则光照稍微动一下模型表面就会出现明显的“换层”现象看起来像贴图瞬间闪变。后面3.3节我会专门讲怎么缓解这个问题。第二个问题轮廓怎么画。素描强调边缘但这里的边缘不只是几何轮廓还要有铅笔手绘的粗细感和轻微抖动感。做起来比较经济的方案是在渲染背面时把顶点沿法线方向外扩一点画成黑色渲染完正面后边缘就自然露出一圈黑色轮廓。这个技巧在卡通渲染里也很常见放到素描里同样成立。1.3 这个风格适合谁、适合什么场景如果你在做独立游戏、美术馆互动装置、或者任何需要快速打造手绘质感的项目素描渲染是很划算的选择。它不需要高精度PBR贴图不需要复杂的IBL只需要六张黑白线条纹理加一个Shader美术成本几乎可以忽略。对于想学习NPR的同学这一章也是很好的入口它把“光照计算”和“纹理资源设计”之间的配合表现得很完整看完就能明白程序端和美术端是怎么对齐需求的。我也见过有人把它用在UI和特效里把三维模型的素描结果渲染到RenderTexture也就是先渲染到一张中间纹理然后再叠加一张纸张纹理出来的画面很像动态分镜。总之这个风格的适用范围比想象中要广后面4.3节会聊一些扩展玩法。2. 核心思路把光照翻译成铅笔线条2.1 六张素描纹理是怎么设计的这一章最核心的资源就是那六张纹理。以我的理解它们可以理解为把灰度空间离散成6档的“铅笔色阶表”。第0档几乎是大片留白只有极少数笔触视觉上接近白色越往后线条数量越多、排线越密第5档几乎是密不透风的十字交叉排线看起来是很深的灰色。渲染时阴影越重的地方采样到的纹理越靠后所以暗部会呈现出密集线条感而不是死黑。纹理设计里有几个注意事项。首先纹理必须无缝平铺因为UV通常会被 _TileFactor 放大很多倍平铺接缝如果太明显整个模型表面会出现一条条竖纹或横纹。其次线条方向尽量不要单一。全用横线会显得很机械比较自然的做法是在同一张纹理里混合横、竖、斜三种方向的笔触或者让不同层级的纹理使用不同的主方向模拟手绘过程中换笔的习惯。如果手边没有现成纹理可以用图像处理软件自己做新建一张灰度图用一个铅笔笔刷或者一个带噪点的线条笔刷画出一组排线然后按密度从低到高导出六张。画的时候先别管颜色专注看线条密度。密度分布不均匀没关系恰好能增加手绘感但每一级之间的密度差要大致均匀不然渲染时会出现某一档变化特别突兀的情况。2.2 halfLambert如何变成纹理层级索引映射计算是我认为这一章最值得抄下来的部分。常规的漫反射是 dot(N, L)范围在-1到1之间。直接用这个值做索引背光面会直接掉到最暗那一档过渡很生硬。所以标准做法是先做一次halfLambert把值压到0到1之间halfLambert dot(N, L) * 0.5 0.5这样从0到1表示从背光到受光。但这只是光照值不是纹理索引。因为总共有6张纹理索引范围是0到5所以下一步是level (1 - halfLambert) * 5这里为什么用1减因为纹理索引越大线条越密、颜色越深而halfLambert值越大代表光照越强光照越强线条应该越稀疏所以要反过来。用一个球体举例假设灯光在相机同侧球面正对灯的部位halfLambert接近1level接近0显示的是最稀疏的留白纹理球面逐渐转向背光halfLambert变小level慢慢变大采样的纹理线条越来越密。这样一个球体看起来就像是用铅笔一层一层涂出来的明暗球。需要注意的是level不是整数在0到5之间连续变化。如果直接取整六个层级之间会有很硬的切换线观感很差。通常做法是把level分成整数部分和小数部分整数部分决定采样哪两张相邻纹理小数部分做两者的线性混合。这样等于给明暗过渡做了一个连续带效果自然很多。2.3 为什么是纹理而不是渐变颜色可能有人会想不用纹理直接用ramp贴图或者lerp一个灰阶颜色不也能表达明暗吗能但效果完全不同。素描的灰度不是那种连续扩散的渐变色它是一笔一笔排出来的图案。光照强时白色区域依然能看到淡淡的纸纹和零星线条光照弱时密集的线条之间有留白并不是均匀的深灰。这些细节都是颜色渐变无法模拟的。纹理方案真正的优点是它同时保存了“亮度信息”和“图案信息”。亮度信息决定我们选择哪一张纹理图案信息决定我们在表面看到什么样的笔触。就算两个像素的光照计算结果完全相同只要它们的UV坐标不同看到的线条位置就不同这给表面带来了巨大的变化。要是换作程序化生成线条要实时做到这种密度的控制计算量会大得多。所以实际项目里几乎都是预制纹理加索引这个思路在风格化渲染中很通用。3. Shader手把手实现描边Pass与主Pass编写要点3.1 描边Pass让轮廓先有铅笔线的样子这个Shader的完整结构是两个Pass。第一个Pass画描边思路是 Cull Front 剔除正面然后把顶点沿法线方向外扩这样背面渲染出来的形状会比原物体略大一圈。因为正面被剔除了不会被画出来所以最终屏幕上只有背面边缘露出的那圈黑色也就是轮廓。代码大概是这样的v2f vert(appdata v) { v2f o; float3 offset normalize(v.normal) * _Outline; o.pos UnityObjectToClipPos(float4(v.vertex.xyz offset, 1.0)); return o; } fixed4 frag(v2f i) : SV_Target { return fixed4(0.0, 0.0, 0.0, 1.0); }顶点着色器里v.vertex.xyz 加上 normalize(v.normal) 乘 _Outline然后 UnityObjectToClipPos 转换到裁剪空间。这里有两个细节要注意。第一外扩要在模型空间做然后一起做MVP变化不要在裁剪空间做否则会按屏幕空间拉伸出问题第二如果模型有非均匀缩放比如Scale是(1, 2, 1)法线方向会被拉歪外扩出来的轮廓会粗细不一。遇到这种模型最好是先归一化到单位缩放再处理或者干脆在三维建模软件里把模型还原成均匀缩放。片元着色器直接返回黑色不需要任何光照计算。_Outline 的值我一般放在0.05到0.15之间太大就变成卡通动画里那种粗描边了太小则容易被主Pass覆盖掉看不出轮廓。3.2 主Pass把光照结果换成纹理采样第二个Pass是全Shader的核心。顶点着色器要输出四样东西裁剪空间坐标、平铺后的UV、世界空间法线、世界空间坐标。平铺UV是关键因为素描纹理通常是一小块排线图需要在物体表面重复多次看起来才像手绘的细密笔触。_TileFactor 建议给到8到20具体看模型大小。片元着色器的流程是先拿世界法线和世界光照方向做点积然后套halfLambert公式接着算出level和过渡量fade再用两个相邻层级分别采样素描纹理最后用lerp合成。核心逻辑可以简化成下面这样float3 worldNormal normalize(i.worldNormal); float3 worldLightDir normalize(UnityWorldSpaceLightDir(i.worldPos)); float halfLambert dot(worldNormal, worldLightDir) * 0.5 0.5; float level (1.0 - halfLambert) * 5.0; float level0 min(floor(level), 4.0); float fade level - level0; // 根据level0选择相邻两张纹理再lerp这里插一句空间转换的经验。我一开始图省事在顶点着色器里把法线转到了对象空间但光照方向是用 UnityWorldSpaceLightDir 拿到的世界空间方向两者直接做点积结果物体一转方向明暗就乱跳。排查了半天最后统一到世界空间才解决。所以记住一条原则跟光照相关的一切计算尽量都放进世界空间。3.3 平滑过渡避免光照移动时纹理闪变如果不做任何处理直接把 level 四舍五入成一个整数然后只采样那一张纹理效果在一段时间内也没问题——直到物体转动或灯光移动。level 值在接近整数边界的地方会来回跳动贴图瞬间更换画面会闪。在素描风格里这种闪变特别显眼因为不同层级的线条密度差异很大和卡通渲染的色阶跳变还不一样后者至少是颜色相近的色块而这里是完全不同的图案。解决办法是把一次采样改成两次采样并用小数部分控制融合。比如 level3.6整数部分是3小数部分是0.6那么同时采样第3层和第4层让第4层占60%的权重。这样在整个过渡带内画面的线条密度是连续变化的不会突然跳档。代价是每个片元要多一次纹理采样但对模型面数和像素量来说通常可以接受。如果你追求更顺滑甚至可以把六张纹理合成一张3D纹理把 level 作为 Z 轴坐标用三线性采样一次搞定。这个改进在3.4节说。不过要注意三线性采样需要在支持体积纹理的平台才能跑移动端要谨慎。3.4 更现代的写法用Texture2DArray管六张纹理上面那段 if-else 分支选纹理的代码功能上没问题但写起来确实啰嗦而且如果有十档纹理就更受不了。Unity 的新版本里可以用 Texture2DArray 把六张图打包成一个数组纹理采样时直接传第三个分量作为索引代码能缩短一大截。示例Texture2DArray _SketchTexArray; SamplerState sampler_SketchTexArray; fixed3 c0 _SketchTexArray.Sample(sampler_SketchTexArray, float3(i.uv, level0)).rgb; fixed3 c1 _SketchTexArray.Sample(sampler_SketchTexArray, float3(i.uv, level0 1)).rgb; fixed3 sketch lerp(c0, c1, fade);优点是代码干净、索引是动态的也方便程序在运行时往数组里填纹理。缺点是要额外注意平台兼容。DX11、Metal、GLES3这些平台没问题但比较老的GLES2设备可能不支持另外在写Shader时要保证纹理数组在C#端先创建好不能在材质面板直接拖六张图了事。如果你只是做PC或新移动设备上的Demo我很推荐这个写法如果目标平台很杂还是用六个sampler2D更稳。4. 实操调优参数、纹理与效果笔记4.1 可以直接拿去用的完整Shader下面把基础版本的完整Shader贴出来。注意我故意保留了 if-else 分支写法因为它兼容性最好也最容易看懂在做什么。如果你要上 Texture2DArray照3.4节的代码替换就行。Shader Custom/SketchStyle { Properties { _Color (Color Tint, Color) (1, 1, 1, 1) _TileFactor (Tile Factor, Float) 8 _Outline (Outline Width, Range(0.02, 0.3)) 0.1 _Sketch0 (Sketch Tone 0, 2D) white {} _Sketch1 (Sketch Tone 1, 2D) white {} _Sketch2 (Sketch Tone 2, 2D) white {} _Sketch3 (Sketch Tone 3, 2D) white {} _Sketch4 (Sketch Tone 4, 2D) white {} _Sketch5 (Sketch Tone 5, 2D) white {} } SubShader { Tags { RenderType Opaque Queue Geometry } // 描边 PassCull Front 顶点外扩 Pass { Cull Front ZWrite On CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc float _Outline; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; }; v2f vert(appdata v) { v2f o; float3 offset normalize(v.normal) * _Outline; o.pos UnityObjectToClipPos(float4(v.vertex.xyz offset, 1.0)); return o; } fixed4 frag(v2f i) : SV_Target { return fixed4(0.0, 0.0, 0.0, 1.0); } ENDCG } // 主 Pass把光照映射到素描纹理 Pass { Tags { LightMode ForwardBase } Cull Back CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdbase #include UnityCG.cginc #include Lighting.cginc fixed4 _Color; float _TileFactor; sampler2D _Sketch0; sampler2D _Sketch1; sampler2D _Sketch2; sampler2D _Sketch3; sampler2D _Sketch4; sampler2D _Sketch5; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldNormal : TEXCOORD1; float3 worldPos : TEXCOORD2; }; v2f vert(appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.uv * _TileFactor; o.worldNormal UnityObjectToWorldNormal(v.normal); o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; return o; } fixed4 frag(v2f i) : SV_Target { float3 worldNormal normalize(i.worldNormal); float3 worldLightDir normalize(UnityWorldSpaceLightDir(i.worldPos)); // halfLambert把 dot 映射到 0~1避免背光面直接压死 float halfLambert dot(worldNormal, worldLightDir) * 0.5 0.5; // 映射到 0~5 的纹理层级光照越弱level 越大 float level (1.0 - halfLambert) * 5.0; float level0 min(floor(level), 4.0); float fade level - level0; // 取两级纹理做平滑过渡 fixed3 c0; if (level0 0.0) c0 tex2D(_Sketch0, i.uv).rgb; else if (level0 1.0) c0 tex2D(_Sketch1, i.uv).rgb; else if (level0 2.0) c0 tex2D(_Sketch2, i.uv).rgb; else if (level0 3.0) c0 tex2D(_Sketch3, i.uv).rgb; else c0 tex2D(_Sketch4, i.uv).rgb; fixed3 c1; if (level0 0.0) c1 tex2D(_Sketch1, i.uv).rgb; else if (level0 1.0) c1 tex2D(_Sketch2, i.uv).rgb; else if (level0 2.0) c1 tex2D(_Sketch3, i.uv).rgb; else if (level0 3.0) c1 tex2D(_Sketch4, i.uv).rgb; else c1 tex2D(_Sketch5, i.uv).rgb; fixed3 sketch lerp(c0, c1, fade); // 叠加物体颜色和光源颜色素描风格下 _Color 最好保持很淡 fixed3 lighting _Color.rgb * _LightColor0.rgb; fixed3 finalColor sketch * lighting; return fixed4(finalColor, 1.0); } ENDCG } } FallBack Diffuse }把这个Shader放进工程后在材质面板给 _Sketch0 到 _Sketch5 分别赋上六张纹理。六张纹理的明暗顺序不要搞反否则会出现暗部全白、亮部全黑的反向效果。第一个Pass的参数只需要关心 _Outline主Pass的参数是 _Color 和 _TileFactor剩下四张纹理就是六张图。运行时把灯从模型侧面慢慢转到正面你会看到线条密度跟着在表面变化这就是整个Shader在工作。4.2 几个关键参数应该怎么调参数调优直接决定观感。我把我的经验整理成了一张表参数推荐区间我的经验_TileFactor8~20数值太小线条被拉得很宽像刷子太大线条挤成黑糊失去铅笔感。一块拳头的模型建议初始给12_Outline0.05~0.150.1附近比较自然超过0.2就变成卡通粗边素描感下降_Color白色或浅灰素描风格下不要给饱和度过高的颜色线条会被颜色盖住六张纹理的密度差每级差异尽量均匀如果某一级过度密集光照转到这里会突然明显变深以我的实际经验_TileFactor 最好不要取整取12.5这种值反而更容易避开UV重复周期带来的规律感。还有一个很实用的技巧在Unity里把六张纹理的 Wrap Mode 都设为 RepeatFilter Mode 设为 Bilinear这样既能无缝平铺采样融合也不会太硬。4.3 想让画面更“手绘”的几点增强基础的六张纹理方案已经能出效果但离真实素描还差一点。我实践下来以下三个增强性价比很高。第一叠加纸张纹理。在最终颜色输出前用纸张贴图叠加一层multiply会让原本干净的电子线条变得有颗粒感更像铅笔在纸面上画的。纸张纹理不用专门去扫用程序生成的噪点纹理也可以。第二增加笔触方向变化。素描高手不会全程只用一个角度排线。我们可以在六张纹理的基础上准备两套方向不同的纹理组根据世界法线的方向在两者之间做混合或者用世界坐标的某几个分量控制UV旋转。这个改动稍微复杂一点但是效果提升非常明显尤其在大面积平面上。第三留一点环境光。纯用平行光计算时背光面会直接落到最密的第5级有时偏黑。可以加一个很小的环境光项让暗部也能看到线条纹理而不是糊成一团黑色。语句很简单在最终颜色里加一个固定小值或 UNITY_LIGHTMODEL_AMBIENT。5. 踩坑记录常见问题速查与解决实录这一节我按问题来写方便以后遇到情况直接查表。很多问题看起来像渲染错误其实根因都是纹理参数、空间转换或过渡处理这些细节。现象主要原因解决办法明暗切换时出现明显纹层闪变level直接取整没有在两个层级间融合用floor和fade拆层级lerp过渡描边断断续续或粗细不一非均匀缩放、_Outline过大、模型接缝处法线突变归一化缩放检查模型法线调小_Outline物体旋转后光照关系错乱法线在对象空间光照方向是世界空间统一用UnityObjectToWorldNormal转到世界空间暗部全黑看不见线条背光面halfLambert过低环境光为0加少量环境光或给halfLambert额外偏移纹理接缝明显Wrap Mode不是Repeat或者纹理本身边缘不连续设为Repeat并在制作纹理时保证边缘无缝5.1 层级闪变这个问题几乎每个手写素描Shader都会遇到。现象是模型表面在光照移动时某一级纹理突然换到另一级像翻书一样。前面3.3节已经讲了解决办法这里补充一个排查技巧你先在片元着色器里把 level 直接当颜色输出如果看到明显的色带说明映射和过渡逻辑有问题如果色带很平滑那问题多半出在纹理采样参数上。5.2 描边断面描边Pass的断线通常不是因为代码而是模型本身。法线外扩对模型法线非常敏感如果有接缝、锐边或者分离面外扩方向会突变轮廓自然就断了。我的建议是先确认模型的法线平滑组正常再做烘焙和渲染。如果模型实在改不了就把 _Outline 调小一点断面会不那么明显或者换用屏幕空间后处理描边不过那工作量就是另一个层级了。5.3 光照空间不一致这个是接手Shader经常翻车的点。简单说N·L 计算里的 N 和 L 必须在同一个空间。Unity 里最常见的空间有三个模型空间、世界空间、观察空间。为了省事全部用世界空间法线用 UnityObjectToWorldNormal光照方向用 UnityWorldSpaceLightDir这样无论物体怎么旋转、缩放结果都是对的。以后写带光照的Shader默认养成就很省心。5.4 关于性能的小提醒描边Pass加主Pass意味着一个物体至少画两遍再加上每个片元要采样两级纹理整体开销并不算小。如果你的场景里充满了素描物体建议合并网格、控制描边Pass的顶点数量或者只在特定相机角度开启描边。移动端上尤其要注意两张素描纹理的双线性采样在低端GPU上会成为瓶颈必要时可以把过渡带收窄或者降低渲染分辨率。读这一章给我最大的收获不是记住了一个素描Shader的写法而是看到了一种“换数据用途”的思路光照强度原本用于调色在素描渲染里变成了纹理索引渲染背面原本是为了剔除在描边Pass里变成生成轮廓的方式。你在一个效果里积累的这些反常识操作到了下一个风格化需求里往往能直接复用。我自己后来做动态绘本项目时就把这套“强度映射纹理层级”的方法用在了水墨和炭笔风格上换掉纹理组就能出不同手感非常省事。最后分享一个我后来才踩明白的小技巧如果要手动准备这六张纹理不要先想着把每张画得多精致而是先做一组粗线条排线测试。丢进Shader里转一转光照确认每一级之间的密度差都舒服再回去精修纹理。纹理和Shader参数的配合比单张纹理好不好看重要得多。