☰
从零搭建风格化渲染系统:卡通渲染色带与描边全解析
2026/10/2 4:37:49 网站建设 项目流程

风格化渲染系统,在游戏和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开始衰减是为了避免过近距离时描边直接消失,留一个最小宽度基线。

后处理这块,我按“渲染到最后画面的整体统一步骤”来设计。风格化画面如果没有统一的后处理,很容易出现场景与角色色调分离。我的后处理栈简单而有效,按顺序执行:

  1. 色调映射:不做ACES,而是使用自定义柔和曲线,把高光区域的过曝收敛到暖白色区间。
  2. 颜色分级:套用一张LUT(Look-Up Table),把整个画面的明度、对比度、色相统一到目标风格基调。
  3. 噪点抖动:在暗部区域叠加一个极低强度的高频噪点,模拟纸面或动画帧的质感。强度控制在0.02到0.05之间,太高会脏。
  4. 可选暗角:轻微暗角帮助视线聚焦到画面中心,强度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个
同屏描边Pass6个
主场景后处理分辨率半分辨率
全屏后处理总数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开头,发布前一键关闭。这个细节省了我很多来回测试的时间。

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

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

立即咨询