
先别急着重新画贴图。这个材质能做到的是把任何现成纹理实时改造成绘画感。不管你是从摄影图库里拖出来的PBR贴图还是程序化生成的一张粗糙材质只要被它接住输出的画面就会带上手绘的色块和轮廓线。它解决的是最让人头疼的“风格不统一”问题一个项目里资产多了美术风格很难靠手工一张张贴图修齐但在渲染层做统一过滤就轻松得多。开头先把目标说清楚这不是滤镜也不是后期描线而是一套参与光照计算的通用Shader。它适合独立游戏开发者、风格化场景美术、做批量材质迭代的团队以及那些想快速验证画面气质又不想返工重画贴图的人。下面我会从原理讲起给出能直接跑的Unity代码把Blender、Substance Painter等流程里的适配问题也一并聊透最后把常见坑和排查顺序整理成表。1. 这个“自带绘画感”的材质解决的是哪一类需求1.1 美术手里的现成纹理往往和项目风格不是一回事如果你在一线做过3D项目大概率经历过这种场面项目风格中期调整原本做好的写实材质全要改成手绘质感。重导贴图重新绘制工作量直接翻倍而且角色、场景、道具加起来几百张贴图根本不是美术团队短期能消化的。我一开始也以为只能硬画直到后来用“材质层改造”的思路整个项目才顺畅起来。所谓“材质层改造”就是不改动贴图文件而是在Shader渲染阶段把纹理的观感重新演绎。这套材质能让你把任意纹理直接变成带手绘质感的画风。它不挑贴图内容照片扫描的PBR材质、程序化生成的纹理、甚至一张简单的色块图都能被统一成同一套绘画语言。实现原理并不复杂但涉及色调量化、轮廓线、笔触感、重复纹理处理等多个细节组合起来才出效果。这个方案适合谁独立游戏制作人、风格化场景美术、做批量场景迭代的团队还有那些想快速看风格化效果但又不想重做资产的原画师。它能解决的核心问题就一句话在不更改纹理资源的情况下让任意纹理实时呈现绘画感。1.2 为什么要用着色器方案而不是后期重绘有人会问用Photoshop或者AI跑一遍转绘不是更快确实快但有两个硬伤。第一转绘后的贴图是静态的看不出光照变化第二场景一旦有镜头远近、打光强弱的变化转绘固定的笔触方向就会露馅。而着色器方案是实时的它跟随灯光、摄像机、模型法线一起参与渲染你转动手电筒、拉近镜头、旋转模型画面依然保持着绘画风味。纯重绘还有另一个问题风格统一度。美术手绘每张图都有手感差异参数化着色器反而能把所有纹理拉到一个统一的风格通道里。这也是很多风格化项目后期倾向于写一套通用NPR渲染材质的原因。关键是一旦这套材质写好后续所有资产的接入成本几乎为零新来的模型只需要挂上材质球就自动统一风格。1.3 我理解的“绘画感”三要素把绘画感拆开看它其实不是“模糊一点、上个滤镜”那么玄。我的经验里手绘质感等于三个要素的叠加颜色层级减少让画面更像色块堆叠而不是像素过渡轮廓线明确让物体有手绘勾边的感觉笔触材质颗粒解决“太干净、太机械”的CG味道。这三个要素相互独立又缺一不可。三个要素对应到技术实现上分别是色调量化、法线或深度边缘检测、UV噪声和视差扰动。后面的章节我会从底层原理讲起给出一套可以落地改的Shader代码再聊聊在Blender、Substance Painter这些流程里的适配问题和踩坑记录。这样你拿到手不是背一段代码而是知道每个旋钮为什么存在。2. 核心原理拆解为什么纹理能自带绘画感2.1 色调量化把连续过渡变成色块正常照片纹理的颜色分布是连续的从亮到暗之间有一大串渐变值。而手绘会主观把颜色归为几大类亮面、暗面、阴影、高光。色调量化做的就是这种归并。实现上最简单的方式是对每个颜色通道除以一个阶梯值再取整。假设我把颜色阶梯设为6一张原本有256级灰度变化的贴图会被强行压缩成6级。具体公式是floor(color * steps) / (steps - 1)。这里需要留意steps设置过大绘画感会明显变弱看起来就是一张普通的减色图如果设置过小比如2到3色彩会出现强烈的断层照片感基本消失只剩下风格化色块。我在默认材质里习惯给5到6亮暗分明过渡也不会太突兀。这里有一个容易踩的坑色调量化作用于纹理采样后的颜色还是作用于光照后的最终颜色效果差别很大。如果在纹理采样时就量化能保证贴图本身的色彩被归纳后续光照会产生色阶跳动如果放在光照后量化会把光照过渡也归纳进去适合做大色块卡通风。我把这个顺序做成了开关具体项目按需切换后面代码里默认放在片元输出阶段画面更干净。2.2 轮廓线用边缘检测代替手绘勾边绘画感的第二个要素是轮廓。手绘作品几乎不会把每个像素都描边它只在边缘转折、明暗交界处下笔。所以单纯依赖纹理的边缘肯定不行必须依赖法线、深度、视角这些几何信息。主流做法分两种一种是基于视线的用NdotV法线点乘视线方向当表面逐渐转向侧面时认为它处于轮廓位置另一种是基于深度的用像素深度和邻近像素深度差做检测适合处理模型互相遮挡的边缘。NdotV这个方案有个特点任何曲面球体的边缘都会产生均匀勾边看上去很“漫画”。深度边缘检测则更接近真实手绘的轮廓变化因为它能捕捉到形体在屏幕上的真实遮挡关系。我在通用材质里把两种都做了默认以NdotV为主辅助少量深度项。轮廓线的强弱由EdgeStrength控制不建议超过2.0太强的勾边会让模型看起来像描边玩具。提示边缘检测如果直接做硬阈值锯齿会非常明显。要像我下面代码里那样用smoothstep把阈值过渡区间拉出来边缘看起来才像手绘线而不是像素锯齿。2.3 笔触感噪声扰动和视差假厚度如果说颜色和轮廓决定了“像不像画”那么笔触感决定了“是哪一种画”。油画有油画的笔触颗粒水彩有颜料的晕染水墨有飞白。用纯Shader模拟真实笔触可以做得很深比如程序化笔刷纹理叠加、切线空间法线扰动、视差遮蔽映射制造颜料的厚度方向感。最简单也最可控的办法是UV噪声扰动。我对UV坐标加一个低频率的hash噪声让纹理的采样位置在微观上发生偏移这样贴图的边缘会产生类似笔触错位、颜料越界的痕迹。噪声频率对应笔刷的大小频率越高笔触越细噪声幅度对应笔触力度幅度太大会让纹理失真。再进阶一点把噪声偏移写到法线上让光影随着笔触方向产生变化画面会更立体。如果项目正好用到了视差材质这个思路可以自然延伸给纹理准备一张Heightmap在片元着色器里按照视线方向做多次采样模拟颜料在表面有真实厚度。这种方案在近距离观察时特别有效颜料边缘会随着视角发生遮挡关系一下子把“平面贴图”变成“立体颜料层”。不过视差对贴图精度要求高Shader开销偏大移动端慎用。2.4 平铺纹理为什么一重复就出戏几乎所有贴图进场景后都要平铺。而重复纹理最大的问题是图案一重复人眼立刻识别出“这是一张贴图铺的”绘画感当场崩塌。因为手绘不会严格按离散单元重复。这个问题在着色器里可以从两个维度解决一是给UV加非重复性的低频噪声扰动二是用多套小成本纹理叠加或者干脆上三平面映射。注意给UV加噪声只能缓解不能根治。要彻底解决平铺感最好让美术在Substance Painter里烘焙出具有一定随机性的纹理再把Tiling密度统一。如果素材已经定了又不想重新烘焙我还有一个土办法把主纹理和另一张tiling周期不同的灰度图混合让两张图互相干扰。两张图的重复周期不一致视觉上的重复感会大幅降低这个办法简单但很实用。3. 实操用Shader写一个通用绘画感材质3.1 环境与工具选择我自己最常写的环境是Unity URP因为URP的Shader结构比Built-in管线清爽SRP Batcher兼容性好一些。Blender材质节点那边也能做相同思路的节点组合但调参体验不如写代码方便。如果你的目标是移动端建议所有噪声都用最基础的hash运算别引入开销过重的噪声纹理。素材准备上一张普通的Albedo贴图足够跑通基础效果。如果想让边缘检测更准再用一张烘焙好的法线贴图效果会明显更好。这套代码我按URP ShaderLab封装兼容Unity 2021之后的URP版本。网上经常搜到的“UG表面材质渲染”相关话题其实指的就是Unity里的URP/Surface Shader方案URP下我们不再用内置管线的Surface Shader形式而是用HLSL的Shader或ShaderGraph做封装。3.2 核心Shader代码与逐段解释先放一个精简版主干都在真实项目在此基础上扩展。Shader Custom/PainterlyTexture { Properties { _MainTex (Texture, 2D) white {} _ColorSteps (Color Steps, Range(2, 16)) 6 _EdgeStrength (Edge Strength, Range(0, 3)) 0.8 _NoiseScale (Noise Scale, Float) 6.0 _NoiseStrength (Noise Strength, Range(0, 0.5)) 0.05 } SubShader { Tags { RenderTypeOpaque } Cull Off Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 normal : TEXCOORD1; float3 viewDir : TEXCOORD2; }; sampler2D _MainTex; float4 _MainTex_ST; float _ColorSteps; float _EdgeStrength; float _NoiseScale; float _NoiseStrength; float hash(float2 p) { return frac(sin(dot(p, float2(12.9898, 78.233))) * 43758.5453); } v2f vert (appdata v) { v2f o; o.pos TransformObjectToHClip(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); o.normal TransformObjectToWorldNormal(v.normal); o.viewDir SafeNormalize(_WorldSpaceCameraPos - TransformObjectToWorld(v.vertex).xyz); return o; } half4 frag (v2f i) : SV_Target { float2 uv i.uv; // 笔触噪声扰动打破像素过于干净的CG感 float2 j float2( hash(floor(uv * _NoiseScale)), hash(floor(uv * _NoiseScale 10.0)) ); uv (j - 0.5) * _NoiseStrength; half4 tex tex2D(_MainTex, uv); // 色调量化把连续渐变归纳成有限色阶 half3 col tex.rgb; col floor(col * _ColorSteps) / max(_ColorSteps - 1, 0.001); // 边缘检测NdotV 平滑过渡 half ndotv saturate(dot(normalize(i.normal), normalize(i.viewDir))); half edge 1.0 - ndotv; edge pow(edge, _EdgeStrength 0.001); edge smoothstep(0.0, 1.0, edge); // 把轮廓线压暗但保留一点透明度避免死黑 col lerp(col, half3(0.04, 0.04, 0.04), edge * 0.45); return half4(col, 1.0); } ENDHLSL } } }这段代码的意思是先采样纹理再用floor做色调量化然后用NdotV生成轮廓最后把轮廓往暗色里混合。注意我开了Cull Off这样模型正反面都会显示就是双面材质。实际使用的时候如果模型是封闭几何体Cull Off反而会让背面的面穿透出来需要按模型情况决定开关。如果想让轮廓线更精细可以把法线NdotV换成屏幕空间深度边缘检测用ddx/ddy或者深度纹理采样来计算边界但代码复杂度会上升不少。我的建议是先跑通这个版本确认画面方向对了再决定要不要加深度项。3.3 参数调节与数值建议参数我调了很长时间给几个初始值参考。ColorSteps默认6适合风格化偏细腻的立绘感想做粗颗粒卡通调到3到4想保留更多中间调调到8到10。EdgeStrength默认0.8画出来的勾边比较细想要“漫画描边”效果就调到1.5以上。NoiseScale控制笔刷大小默认6意味着UV上每隔六分之一的周期噪声偏移一次。值越大笔触越碎户外大地形用2到4就有粗糙的笔触感角色局部特写用8到12会更精致。NoiseStrength建议控制在0.02到0.08超过0.1会让贴图出现明显的错位飘移看起来像故障而不是绘画。这套参数不绝对但作为起步值能省掉你很多试错时间。3.4 在URP和Built-in里的接入差别URP下使用这代码要注意Shader必须放在URP的渲染管线里否则编译报错。Built-in管线使用的话要把include换成UnityCG.cgincTransformObjectToHClip换成UnityObjectToClipPos_WorldSpaceCameraPos保持不变。本质上差异不大但项目里如果还有老管线材质同时维护两套变体会有点烦。URP里还有个容易被忽略的问题SRP Batcher的兼容性。Properties里的材质属性需要手动放进CBUFFERUnity会自动处理一部分但自定义的材质属性建议手动声明。我发现很多美术同事抱怨“改参数没反应”十有八九是CBUFFER里少声明了属性导致SRP Batcher合批时把数据给裁掉了。真正要用的做法是把_MainTex_ST、_ColorSteps、_EdgeStrength这些变量挪到CBUFFER_START(UnityPerMaterial)和CBUFFER_END之间上面的示例为了简洁省略了落项目时不要省。4. 不同工具链的适配与避坑4.1 Unity双面材质与背面法线翻转双面材质应该是被搜得最多的话题因为很多3D工具里默认只画正面模型只要有一处法线反了贴图就“消失”。这个材质默认Cull Off可以避免一部分贴图消失问题。但开了Cull Off之后背面法线和视线方向相反NdotV会计算出错误的轮廓结果。解决办法是在片元着色器里判断法线和视线夹角如果法线指向视线反面手动反转法线再计算。Shader里通常用if (dot(normal, viewDir) 0) normal -normal;的方式处理否则双面材质背面会出现一团乱的描边和阴影。这是很多双面Shader共通的坑处理好了树叶、纸张、布料这类薄片模型才能既透光又有绘画感。4.2 Blender重复纹理与UV密度在Blender里做风格化材质可以用Shader Node搭类似的流程。最核心的节点是Texture Coordinate和Mapping。很多人觉得Blender重复纹理难看是因为UV比例没有统一。两套模型分别展开UV时同一个贴图在同一尺度下的密度不同看起来一块疏一块密。你要把Image Texture的Repeat和Mapping的Scale对应起来tiling密度才会一致。如果你想在Blender里削弱平铺感可以用Brick Texture或Wave Texture叠加到主纹理上通过Rotation和Scale做一个低频扰动。不过Blender材质节点的性能优化不如Unity灵活节点一多很难维护。我一般只用节点做快速效果验证最终成品还是会回到Shader里实现。Blender用户还要记得如果模型面朝向反了材质同样会“消失”这和SketchUp的贴图不显示纹理是同一个道理。4.3 Substance Painter导出与纹理压缩的坑Substance Painter烘焙和绘制的纹理本身质量很高但一旦导入引擎纹理压缩会立刻改变观感。尤其量化类Shader对压缩很敏感BC1格式只保留RGB565级别的色彩色块边缘容易出现杂色噪块BC7或ASTC 6x6会好不少但依然会改变颜色分布范围。所以导出贴图时要注意Albedo/BaseColor设置成sRGB法线设置成Linear粗糙度、金属度也按引擎要求设置。路径方面Substance Painter默认导出的路径容易带中文或空格引擎读取不到就会显得“无纹理”。建议在Export Configuration里把输出路径固定到纯英文目录下免得后面排查半天。如果你用的是Substance Painter的“材质路径”功能也要时刻注意工程打包后路径是否还对应得上。还有一点压缩格式对量化色带影响极大。我自己测试过同一张贴图Bc7效果远优于DXT5移动端ASTC 6x6画质和体积能达到一个不错的平衡。工程里我通常会压成ASTC 8x8质量和容量都挺好但如果你做的是PC端精品项目建议原始尺寸加BC7。4.4 纹理内部和像素内部的区别这个热搜词我理解为很多人问“在纹理空间做效果和屏幕空间做效果有什么不同”。展开来说纹理内部的离散单元叫texel屏幕最终像素叫pixel。色调量化如果放在采样纹理之前对UV做量化得到的是纹理内部的色块如果放在片元输出前对颜色做量化是连续变化区域被打断成像素色块。对绘画感来说两者观感完全不同。纹理内部量化适合做颗粒感强的风格像素内部量化适合做干净平滑的卡通风格。所以这个材质里我把量化放在输出阶段而不是UV阶段画面看起来更整洁。如果你想追求粗糙的颜料颗粒感可以把量化阈值放到UV采样前后试探用纹理内部量化和像素量化组合出不同的笔触质感。5. 常见问题与排查技巧实录5.1 贴图不显示或者直接全黑遇到贴图不显示不要一上来就改Shader。我惯用的排查顺序是先看贴图导入设置里的sRGB有没有勾对再看模型UV有没有正常展开然后检查材质球里有没有正确赋上Texture最后才回头看Shader代码。SketchUp里经常出现的“贴图不显示纹理”原因多数是面朝向反了材质只赋给了正面翻转面后再赋一次就解决问题。如果材质路径变了比如Substance Painter导出的路径丢了引擎会显示成默认白色材质。这时候到Project窗口看Inspector里的Shader属性通常会有一条缺少纹理的错误提示。我的习惯是在工程根目录建统一Textures文件夹所有外部工具导出的贴图都丢进去这样路径引用不容易丢团队协作时也不会各找各的贴图。5.2 色调量化之后色带严重像“断层”色带是量化Shader的必然产物但用户一般不希望它太难看。关键不是取消量化而是让量化边界更柔和。两个办法常用一是在量化前给颜色加一丢丢噪声让色带边界抖开缺点是有噪点二是做三分法混合把量化颜色和原始颜色按权重混合在暗部保留量化感在高光附近保留平滑过渡。压缩格式的影响也要注意。如果压缩后色带还带一圈圈色彩波纹说明贴图本身被过度压缩建议重做成ASTC或BC7同时把Mipmap的LOD Bias减小一点让中远景的过渡更自然。这里我多提一句纹理压缩后色带边缘往往会出现脏色尤其是红色区域这种情况下尽量别用BC1。5.3 模型一放大平铺纹理就露馅地表、墙面这类大尺寸模型最容易在平铺上出问题。我试过最有效的手段是“低频噪声双纹理交叉”。把UV乘以一个2到4的频率用hash产生一个大致连续的随机偏移再叠加第二张不同tiling的纹理重复周期立刻变乱。这个方法在写实场景里几乎能做到底。没有明显重复感代价只是一次额外的贴图采样。如果是从外部导入的烘焙纹理记得把Wrap Mode设成Repeat而不是Clamp配合Mipmap变化。很多人忽略的是法线贴图的平铺问题它比颜色贴图更显眼因为法线直接影响光照连续性。地面法线贴图平铺周期太短时光照会出现“格子状”这种情况要把法线强度调低或者用三平面映射代替普通UV采样。5.4 轮廓线抖动、闪烁、锯齿严重轮廓线锯齿大部分来自边缘检测的阈值没有平滑过渡。解决思路是用smoothstep代替step把连续变化的NdotV值映射到0到1的渐变区间。如果用的是深度边缘检测还要对深度差做归一化让远处和近处的边缘粗细保持统一。还有一条我在被美术反复反馈后总结的经验轮廓线不要整条都压成纯黑色稍微留一点透明度或者叠加一点暗红、暗蓝染色会让手绘感更真实。纯黑勾边在某些屏幕上会因为压缩和伽马换算产生色斑观感反而没有深灰加渐隐的描边舒服。5.5 常见问题速查现象可能原因排查方向贴图全黑sRGB勾选错误、UV缺失、路径丢失导入设置、UV展开、材质路径画面发灰色彩空间不一致Linear和sRGB的混合使用色带断裂严重量化级别太低、压缩格式太差ColorSteps、压缩格式换BC7/ASTC平铺感明显UV密度不统一、噪声频率太低统一Tiling、加交叉纹理背面发黑或描边错乱开了Cull Off但没翻转法线片元里判断法线朝向并翻转勾边锯齿阈值使用硬step改成smoothstep过渡6. 个人实践心得与后续扩展这套材质我已经在好几个类型的项目里来回用了几轮最大的感受是它解决的是风格统一问题不是替代美术创作。你拿一张特别糟糕的纹理丢进来它能拉回一点观感但不会凭空变出高质量细节。所以至少先保证纹理光影合理、色彩干净再交给这个着色器做风格化演绎。移动端使用的时候尽量把噪声函数换成低精度版本或者直接去掉。移动平台的GPU对sin和cos这类复杂运算不大友好一个hash函数可能就把帧时间拉上去。PC端则可以把边缘检测从NdotV升级成深度检测配合后处理做一次扰动画面质感和动态感会上一个台阶。后续我打算在里面加两样东西一个是顶点色驱动的局部风化效果让绘画感在不同部件之间有差异另一个是基于Surface Shader的面部阴影色阶替代现在整个模型统一的色阶数。如果你也在做风格化渲染这几个方向值得一起摸索。