☰
把任意贴图实时改造成手绘质感:一套通用绘画感Shader方案
2026/9/30 9:52:53 网站建设 项目流程

先别急着重新画贴图。这个材质能做到的,是把任何现成纹理实时改造成绘画感。不管你是从摄影图库里拖出来的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 { "RenderType"="Opaque" } 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.cginc,TransformObjectToHClip换成UnityObjectToClipPos,_WorldSpaceCameraPos保持不变。本质上差异不大,但项目里如果还有老管线材质,同时维护两套变体,会有点烦。

URP里还有个容易被忽略的问题:SRP Batcher的兼容性。Properties里的材质属性需要手动放进CBUFFER,Unity会自动处理一部分,但自定义的材质属性建议手动声明。我发现很多美术同事抱怨“改参数没反应”,十有八九是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的面部阴影色阶,替代现在整个模型统一的色阶数。如果你也在做风格化渲染,这几个方向值得一起摸索。

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

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

立即咨询