风格化渲染系统,在游戏和CG创作领域这两年算得上高频词。很多人刚接触时以为它是“给画面套个滤镜”,真正动手做一套才明白,这意味着把光照模型、材质组织、描边方案、后处理管线全部推倒重建。这篇文章把我从零搭建一套风格化渲染系统的过程完整记录下来,涵盖卡通色带、描边、Ramp贴图、后处理整合以及移动端适配,重点讲清楚每一步为什么这么选,以及那些教程里不常写的坑。内容主要面向刚开始接触NPR渲染的TA、图形程序,以及想自己动手手写风格化材质的客户端开发者。
1. 项目背景与整体设计思路
1.1 风格化渲染要解决的核心问题
先明确一件事:风格化渲染不是“把效果调得好看一点”,而是要建立一套和物理渲染完全不同的视觉逻辑。PBR光照的目标是模拟真实光线传播,漫反射、镜面反射、微表面高光都讲究物理正确;风格化渲染的目标是让画面看起来有“绘画感”“手绘感”,它允许光照结果违背物理直觉,只要自洽、好看、符合美术设定就行。
所以这个系统解决的核心问题是:怎么用一套可控的、可复用的渲染规则,把光照、阴影、高光、边缘甚至画面整体色调都变成“美术可以调、程序可以管”的参数。说得再具体一点,是要回答三个问题:阴影边界怎么变成清晰的色带,角色和物体的轮廓线用什么方式提取,画面整体颜色怎么统一到一种风格基调里。这三个问题任何一个处理不好,画面就会在“写实与卡通之间反复横跳”,而不是稳定地呈现一种风格。
1.2 技术道路选型:为什么先走卡通渲染路线
风格化渲染的范围极大,包含卡通渲染、水彩、油画、素描、低多边形、像素风等方向。我最终选择以卡通渲染(Cel/Toon Shading)为主路线,再叠加少量手绘质感效果,主要有三个原因。
第一,卡通渲染的视觉特征最明确,容易验证效果。色带阴影加上轮廓线,这两个特征一上,任何人都能立刻看出“这是风格化画面”,反馈链路短。
第二,卡通渲染的算法在实时渲染中成本可控。经典的做法是基于NdotL做阶梯化,配合Ramp贴图控制阴影颜色,再加一个基于顶点膨胀的描边Pass,整体开销比全局光照和屏幕空间反射小很多,在移动端也能跑。
第三,卡通渲染是其他风格的基础。水彩和素描效果通常需要在卡通渲染的框架上叠加纸张纹理、笔画扰动、颜色抖动等后效,相当于在“干净的卡通底子”上做文章。先把卡通渲染跑通,后续扩展方向就多。
如果一开始就选水彩或素描风格,会陷入细节泥潭——模拟纸张纤维、笔触方向、颜料扩散都会耗费大量时间,而且不同模型在画风下的适配难度极高。所以我的建议是:除非你有明确且单一的演示需求,否则第一版先把卡通渲染做成一个稳定底座。
1.3 系统顶层架构
整套系统的顶层模块,我按功能拆成了五个部分:
- 光照模块:负责漫反射色带、高光色带、边缘光、环境光遮蔽的计算。
- 描边模块:负责轮廓线、折痕线、可选的分级描边(近粗远细)。
- 材质模块:负责基础色贴图、Ramp贴图的接口规范,以及发型、皮肤、金属等带特殊光照规则的子材质。
- 后处理模块:负责色调映射、颜色分级、噪点抖动、可选暗角。
- 参数管理模块:负责暴露给美术调色的全局参数,以及各模型材质实例的统一配置入口。
为什么要区分得这么细?因为“系统”和“一个Shader”的区别,就在于支持多少场景与角色复用。只有每个模块的接口边界清晰,才能让后续新角色、新场景只改参数不动代码。实际项目里我最常遇到的情况是:角色效果已经调好,换一个新场景灯光方向一变,角色脸上整片暗部失效。就是因为光照参数和场景光源没解耦,这类问题必须在架构层面防范。
2. 核心模块细节拆解
2.1 光照模型:色带分层怎么算才稳定
卡通渲染的漫反射核心是色带。传统写法是求出NdotL后用阶梯函数量化,让它只取有限的几档亮度。第一步先做一个基础版本:
float NdotL = dot(normalWS, lightDirWS); NdotL = NdotL * 0.5 + 0.5; // 从[-1,1]平移到[0,1] float band = floor(NdotL * _StepCount) / _StepCount; band = saturate(band);这里将NdotL映射到0到1的范围,再用floor做阶梯量化。_StepCount是档位数,数值越大,阴影过渡越细腻,风格化越弱。很多卡通角色用2到3档就够,场景道具可以给到4到5档。
但直接这样写会在暗部出现一个很严重的问题:NdotL接近0时,band会直接跳到0,导致整个背光面一片死黑。写实渲染里暗部确实应该接近黑,但卡通画面需要保证暗部有颜色信息,否则角色在背光时会像被剪影切掉。所以需要引入一个“半Lambert”范围的概念,把明暗交界线附近的过渡拉开:
float halfLambert = NdotL * _ShadowRange + (1.0 - _ShadowRange) * 0.5; halfLambert = saturate(halfLambert);_ShadowRange一般在0.6到0.8之间,作用是把阴影边界控制在角色面部和身体的一侧,而不是让暗部直接塌到底。这里的实质是调整明暗过渡的起点,让阴影色带的宽度可控。
除了漫反射,高光也要做风格化。PBR里的高光是连续渐变,卡通渲染的高光通常用一块偏平的亮斑。可以用smoothstep对高光系数做一档量化,再加一个高光Ramp贴图控制宽度,或者直接用高光阈值函数:
float halfDir = normalize(lightDirWS + viewDirWS); float NdotH = dot(normalWS, halfDir); float specMask = smoothstep(_SpecEdge, _SpecEdge + _SpecSmooth, NdotH);_specEdge控制高光收缩的阈值,_SpecSmooth控制高光边界软硬。这两个参数组合起来,能比较自由地控制高光是一颗小圆点还是一片大亮斑。
还有一个容易忽略的点,层级式AO。卡通风格需要更干净的轮廓层次,如果完全没有AO参与,身体四肢交汇处阴影会显得“空”。我的处理方法是把AO也做阶梯化,而不是用写实风格的连续灰阶:
float aoBand = floor(ao * _AoStepCount) / _AoStepCount;这样AO不会出现细碎的灰色过渡,而是和主色带保持一致的大色块风格。稳定、干净,而且视觉上很舒服。
2.2 描边模块:顶点膨胀与屏幕空间的取舍
描边是风格化渲染最显眼的特征。我实际试过两种主流的做法,各有明确的适用场景。
第一种是顶点膨胀描边(Inverted Hull)。原理是在模型上做一个只画背面的Pass,把顶点沿着法线方向向外推一点,然后渲染成轮廓色。核心Shader逻辑如下:
Pass { Cull Front ZWrite Off vertexOutput vert(vertexInput input) { float3 normalWS = TransformObjectToWorldNormal(input.normal); float3 posWS = TransformObjectToWorld(input.position); posWS += normalWS * _OutlineWidth; // 沿法线方向外扩 output.position = TransformWorldToHClip(posWS); return output; } }这个方案的优点是性能极好,只有一个额外Pass,而且线条粗细可以通过参数统一控制,不依赖屏幕分辨率。缺点是轮廓线不是真正的“轮廓”,模型表面所有朝向边缘的转折都会画出线,内部需要靠顶点色遮罩或者法线阈值过滤。
第二种是屏幕空间边缘检测。做法是采深度法线纹理,用Sobel检测深度或法线的不连续处,在Post Process阶段生成线条,叠加到画面中。
屏幕空间方案的最大优势是线条形态精准,模型内部不会误画,也不会出现顶点膨胀造成的描边穿插。缺点也很明显:全屏后处理成本更高,线条粗细受分辨率影响,而且深度法线检测出来的边缘可能包含场景中所有物体的轮廓,想单独控制某个角色不出线会很麻烦。
我在实际项目里采用的是混合策略:角色主描边用顶点膨胀,角色裙摆、头发等细节处用顶点色Mask控制线条宽度;场景建筑的描边不用顶点膨胀,而是用深度法线后处理统一生成,保证远景建筑线条规整。
描边遇到的问题普遍集中在粗细控制上。最有效的解法是给描边加一个基于视线夹角的衰减系数——模型正面朝向相机时描边最细,侧面视角时描边自动加粗。实现时用NdotV参与宽度计算:
float viewFactor = saturate(dot(normalWS, viewDirWS)); float finalWidth = _OutlineWidth * lerp(_OutlineFade, 1.0, viewFactor);这样一个简单的系数,就能避免角色从正面转向侧面时,描边宽度突然变化带来的视觉跳变。
2.3 材质与贴图接口:让美术可以“只改参数”
风格化渲染系统里有一个很容易被忽略、但决定项目能否多人协作的模块:材质参数接口。
我见过的很多自制风格化渲染系统,问题出在Shader里每个模型都有一堆互相冲突的开关。比如一个角色需要A方案阴影,另一个角色需要B方案高光,最后Shader里加了一堆if分支,或者干脆同一种材质复制出十几个版本。这种维护方式会让后期调试成本成倍增长。
我的做法是定义一套统一的材质参数集合,无论角色还是场景都走同一套Shader变体,仅在材质实例上调整数值:
| 参数组 | 参数名 | 作用 |
|---|---|---|
| 基础色 | _BaseTex_BaseColor | 贴图与整体色调 |
| 阴影控制 | _StepCount_ShadowRange_ShadowSmooth | 色带档位、明暗位置、过渡软硬 |
| Ramp | _RampTex_RampVCoord | 漫反射渐变贴图及其采样起始位置 |
| 高光 | _SpecularMask_SpecularStrength_SpecEdge_SpecSmooth | 高光开关、强度、圆斑大小、软硬 |
| 描边 | _OutlineToggle_OutlineWidth_OutlineFade_OutlineColor | 描边开关、粗细、远近衰减、颜色 |
| 边缘光 | _RimToggle_RimColor_RimStrength_RimSmooth | 边缘亮线开关、颜色、强度、过渡 |
这套接口看起来很普通,但关键在于“美术可以调的参数全部开放,不能调的埋死在Shader里”。比如色带层数,我开放给美术的是_StepCount,取值范围2到5,超过5就没有风格了,不需要让美术理解浮点阶梯函数的细节。高光的软硬参数也只给两个滑条,而不是让美术直接编辑Blinn-Phong的指数。
我在研发过程中踩过一个很具体的坑:同一个Ramp贴图在不同角色身上,阴影颜色完全对不上。排查后发现是_RampVCoord在不同的材质实例里被改成了0.1和0.5,导致采样点落在了Ramp纹理的不同行。后来我把Ramp贴图的纵向采样统一固定为0.5,只允许美术更换贴图,不允许调整采样行坐标,问题彻底消失。
3. 实操过程:一步步搭起风格化渲染管线
3.1 搭建Shader模板与Pass结构
我以Unity URP为例搭建整套系统,因为它的ShaderLab结构清晰,C#侧可控性强,而且移动端适配经验最丰富。第一步先建立Shader骨架,分为两个Pass:主光照Pass和描边Pass。
Shader "NPR/ToonLit" { Properties { _BaseTex ("Base Texture", 2D) = "white" {} _BaseColor ("Base Color", Color) = (1,1,1,1) _StepCount ("Step Count", Range(1,5)) = 3 _ShadowRange ("Shadow Range", Range(0.2,0.9)) = 0.7 _ShadowSmooth ("Shadow Smooth", Range(0.001,0.2)) = 0.03 _RampTex ("Ramp Texture", 2D) = "white" {} _SpecularColor ("Specular Color", Color) = (1,1,1,1) _SpecularStrength ("Specular Strength", Range(0,1)) = 0.5 _SpecEdge ("Specular Edge", Range(0,0.99)) = 0.9 _SpecSmooth ("Specular Smooth", Range(0.001,0.1)) = 0.02 _OutlineToggle ("Outline Toggle", Range(0,1)) = 1 _OutlineWidth ("Outline Width", Range(0,0.05)) = 0.01 _OutlineFade ("Outline Fade", Range(0,1)) = 0.15 _OutlineColor ("Outline Color", Color) = (0.05,0.04,0.02,1) _RimColor ("Rim Color", Color) = (0.5,0.7,1,1) _RimStrength ("Rim Strength", Range(0,1)) = 0.3 _RimSmooth ("Rim Smooth", Range(0.01,1)) = 0.5 } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" } Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } Cull Back HLSLPROGRAM #pragma vertex vert #pragma fragment frag // 这里写光照、色带、Ramp采样、高光、边缘光逻辑 ENDHLSL } Pass { Name "Outline" Cull Front ZWrite Off HLSLPROGRAM #pragma vertex vertOutline #pragma fragment fragOutline // 顶点膨胀描边逻辑 ENDHLSL } } }这里最关键的两个结构决策:描边Pass的ZWrite Off是为了避免描边Pass写入深度影响后续透明物体;Cull Front确保膨胀的是模型背面,避免正面渲染轮廓色。如果遇到描边被遮挡的问题,可以改为ZWrite On,但要测试透明队列的情况。
3.2 Ramp贴图的使用与导入参数调试
Ramp贴图的作用是把光照角度映射到一个自定义的颜色渐变上。制作方式不复杂:在Photoshop里生成一张横向彩条渐变图,比如从暖黄色到冷紫色,代表亮部到暗部的颜色倾向。
需要注意导入参数,最容易出错的就是Ramp纹理被当成sRGB导致颜色偏移。Ramp贴图通常应该关闭sRGB标记,使用Linear采样。同时采样模式设为Clamp而不是Repeat,否则NdotL的值在边缘可能导致贴图首尾颜色被拉伸或循环。
Ramp采样坐标的计算方式:
float rampCoord = saturate(halfLambert); float3 rampColor = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, float2(rampCoord, 0.5)).rgb;最终漫反射颜色是基础色贴图颜色和Ramp颜色的乘积。这样Ramp控制的是光照色彩倾向,基础色贴图控制物体本身的固有颜色,二者职责清晰。
我在实际调参过程中,花了很多时间处理“Ramp贴图色带断层”的问题。原因是采样坐标被MipMap自动降级后,贴图颜色取到中间平均值的概率变大,渐变色变得灰、脏。处理方法是干脆关掉该贴图的MipMap,或者在Shader中使用显式LOD采样。以256x16的Ramp贴图为例,关闭MipMap完全不影响效果,反而更稳定。
3.3 描边Pass与后处理整合
描边Pass的代码我在2.2节已经给过核心逻辑,这里重点说整合方式。描边Pass在主光照Pass之后执行,颜色直接覆盖到模型背面膨胀出的区域上。为了控制近粗远细的效果,使用_OutlineFade做距离衰减:
float dist = length(_WorldSpaceCameraPos - posWS); float widthFactor = saturate(_OutlineFade * dist); widthFactor = lerp(0.3, 1.0, widthFactor); posWS += normalWS * _OutlineWidth * widthFactor;从0.3开始衰减是为了避免过近距离时描边直接消失,留一个最小宽度基线。
后处理这块,我按“渲染到最后画面的整体统一步骤”来设计。风格化画面如果没有统一的后处理,很容易出现场景与角色色调分离。我的后处理栈简单而有效,按顺序执行:
- 色调映射:不做ACES,而是使用自定义柔和曲线,把高光区域的过曝收敛到暖白色区间。
- 颜色分级:套用一张LUT(Look-Up Table),把整个画面的明度、对比度、色相统一到目标风格基调。
- 噪点抖动:在暗部区域叠加一个极低强度的高频噪点,模拟纸面或动画帧的质感。强度控制在0.02到0.05之间,太高会脏。
- 可选暗角:轻微暗角帮助视线聚焦到画面中心,强度0.15到0.25。
后处理的参数我整理成了一套初始推荐值:
| 后处理项 | 参数 | 初始值时 |
|---|---|---|
| 色调映射 | 高光曲线强度 | 0.6 |
| 颜色分级 | LUT强度 | 0.85 |
| 噪点 | 强度 | 0.03 |
| 噪点 | 频率 | 1.0 |
| 暗角 | 强度 | 0.2 |
这套值只适合卡通风格,如果你的目标风格是“轻写实”(类似动画电影质感),LUT强度要降到0.4以下,噪点强度降到0.01以下,暗角直接关掉。
4. 常见问题与排查思路实录
4.1 色带断层与光照跳变怎么修
色带断层,也就是阴影边缘出现一截不自然的多边形色块,这是风格化渲染最典型的Bug。原理是低精度Ramp贴图配合NdotL在边界处采样不连续,加上HDR颜色空间转换时差值被放大。
排查思路分三步:
第一步检查Ramp贴图的导入设置。MipMap是否关闭、FilterMode是否为Bilinear。如果是Point会自动出现锯齿感,看起来像断层。WrapMode是否为Clamp,如果是Repeat会在线条两端出现渐变接缝。
第二步检查色带量化逻辑。如果用的是floor,注意_StepCount取值是否和Ramp的色阶数量对应。我曾经把_StepCount设为4,但Ramp贴图只有3种颜色,结果第二档和第三档颜色完全没区分,视觉上像错位。
第三步检查像素精度。在低分辨率移动端,NdotL参与计算时如果精度不够,会出现噪点抖动。解决方案是把场景光的方向在CPU侧归一化一次,在Shader中不再做多次Normalize,同时将关键中间量改为half精度即可。
如果前三步都检查完还跳变,最有效的修复方案是给色带边界加一层过渡:
float threshold = _StepCount; float band = floor(halfLambert * threshold) / threshold; float nextBand = min(band + 1.0 / threshold, 1.0); float blend = smoothstep(band, nextBand, halfLambert); float finalDiffuse = lerp(band, nextBand, blend * _ShadowSmooth);这个逻辑的本质是允许“色带分界处有少量渐变覆盖”,既保留分层感,又消除闪烁。
4.2 描边断裂、粗细失矩怎么处理
描边断裂的原因通常有三个:模型法线有问题、顶点色遮罩没做、ZTest冲突。模型法线出错是最常见的,特别是从外部软件导入的模型没有统一顶点法线,导致膨胀方向不一致,描边在关节处扭曲断裂。
我的处理方案是:模型导入时强制重新计算顶点法线,把平滑角度设置为60度以上。头发、裙摆这类需要更细描边的部分,用UV2通道烘焙一张顶点色遮罩,在描边宽度计算时乘以遮罩值:
float mask = tex2D(_OutlineMask, input.uv).r; float finalWidth = _OutlineWidth * mask * widthFactor;这样美术可以直接在模型的UV2通道里绘制描边粗细,不用改Shader。
描边过粗还有一个常见原因:透视投影下,模型离相机越近,像素边缘被放大得越多。解决方式是裁减空间Y轴做反向缩放,或者直接在描边宽度上增加一个基于角度的衰减。我在4.1节已经提到用NdotV加权,这个方案实测下来效果最好。
还有一个特殊问题:带透明材质的物体,比如头发片、玻璃道具,如果开ZWrite Off,描边Pass会穿透后面模型的正面,产生线条闪烁。解决方法是透明材质的描边Pass加上ZWrite On,确保描边在深度上正确遮挡。
4.3 移动端性能与批次优化
风格化渲染看起来比写实渲染简单,但性能并不一定更省。原因在于多层Pass和全屏后处理会吃掉大量GPU带宽。我实际测试过,在骁龙8 Gen2的移动设备上,一个中型角色占据屏幕三分之一面积时,主光照Pass加上描边Pass,两个模型的DrawCall从2次跳到4次,全场景加后处理最终DrawCall到了200以上。
移动端优化的原则有三个:
- 减少Pass数量。主光照Pass和描边Pass可以合并成一个Shader变体,用一个宏开关控制是否执行描边。不开描边的物件(比如远景)直接跳过第二个Pass。
- 后处理半分辨率。全屏操作在1080p和720p的性能差接近一倍。我实测在移动端做后处理时,把分辨率降到720p,噪点和LUT的效果几乎不受影响,帧率能提高15%到20%。
- 使用SRP Batcher。URP内置的合批管线要求Shader使用CBuffer声明常量。我的Shader统一改成CBUFFER方式后,材质参数不再产生单独批次。
具体参数预算可以参考这套配置:
| 项目 | 目标值 |
|---|---|
| 同屏角色数 | 6个 |
| 同屏描边Pass | 6个 |
| 主场景后处理分辨率 | 半分辨率 |
| 全屏后处理总数 | 3个 |
| DrawCall预算 | 250 |
| 目标帧率 | 30fps |
值得注意的是,风格化渲染的“低画质”和写实渲染不同。写实降画质是牺牲贴图精度和阴影质量,风格化降画质是减少色带档位、关闭全屏效果。移动端默认用2档色带,打开描边但关闭屏幕空间后处理,画面仍然能保持清晰完整的风格特征。
4.4 风格统一性与调色管理
多场景多角色最头疼的问题不是单个画面难看,而是每个画面各有各的难看风格。角色由A材质、场景由B材质、天气由后处理控制,最终合到一起颜色完全不在一个体系里。
我落地了一套调色管理方案,效果比较稳定:
第一步,全局色板约束。在项目根目录放一个全局调色配置文件,定义主要颜色(主角色色、阴影色、高光色、描边色、环境色),所有材质实例的默认值都从这套色板读取。这样美术调整角色颜色时,系统会提示关联材质同步更新。
第二步,LUT统一分级。同一套风格的所有场景必须使用同一张LUT。LUT负责“最终整体印象”,材质参数负责“个体差异”,二者等级分明。只要LUT不换,场景换再多个,颜色基调都不会乱。
第三步,材质实例参数规范。在材质命名中使用前缀标识风格类型,比如NPR_Hair_Tsurugi_Dark、NPR_Metal_Utsuwa_Light。这些命名约定避免了美术重复创建同功能的冲突材质,也方便程序快速定位问题。
这套管理方案看起来不像技术能力,但实际对系统的稳定性贡献非常大。很多项目最后崩塌都不是渲染代码出错,而是几百个材质实例的参数互相覆盖,调色调了三天发现改的是错误实例。
5. 新手常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 阴影断裂成多边形色块 | Ramp贴图MipMap未关闭 | 关闭MipMap,改为Clamp+Linear |
| 色带边缘闪烁 | NdotL精度不足 | 场景光在CPU侧归一化,中间变量用half |
| 描边在关节处断裂 | 模型顶点法线不平滑 | 导入时重算法线,平滑角60度以上 |
| 描边过粗、近处不受控 | 透视投影放大 | 用NdotV做宽度衰减 |
| 透明物体描边穿透 | ZWrite Off冲突 | 透明材质描边Pass改为ZWrite On |
| 材质有十几份重复实例 | 参数管理混乱 | 统一材质参数集合,用前缀命名规范 |
| 场景角色色调不统一 | 后处理LUT不一致 | 统一全局LUT,材质默认值从全局色板读取 |
| 移动端帧率不足 | Pass数和后处理太多 | 减少Pass,半分辨率后处理,启用SRP Batcher |
| Ramp采样颜色偏脏 | sRGB标记错误 | 导入时关闭sRGB,用Linear采样 |
| 高光边缘生硬有锯齿 | 高光系数量化缺少过渡 | 用smoothstep软化高光边界 |
个人实操心得
整套系统落地后,我最大的体会是:风格化渲染系统的难点百分之六十不在算法,而在接口设计和参数治理。色带和描边的原始算法每个人都可以写出来,但能不能让美术在三天内调出三个风格迥异的角色、能不能在换场景后保持画面统一、能不能在移动端和PC端同时稳定运行,这才是“系统”二字的真正含义。
如果你是从零开始做第一版,建议选一个球体和一个带面部的角色模型,先跑通色带两档、Ramp贴图一条、描边一档,然后再逐步加高光、边缘光、后处理。不要一上来就同时追求水彩、素描、油画等多种风格,那不是风格的丰富,是可控性的灾难。先把这个清清爽爽的卡通底座打稳,后续往任何方向扩展都有底气。
工具链方面,Unity URP对我的开发效率帮助很大,ShaderGraph虽然方便美术调试,但要实现精确的色带阶梯逻辑,还是手写HLSL更可控。环境搭建我建议直接用URP自带模板工程,配合RenderFeature做后处理注入,比修改内置管线稳定得多。最后再分享一个小技巧:把Shader里所有调试参数统一命名成_Debug开头,发布前一键关闭。这个细节省了我很多来回测试的时间。