ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Unity URP卡通渲染:Sobel+法线深度描边实战

Unity URP卡通渲染:Sobel+法线深度描边实战

做卡通渲染的同学大多踩过同一个坑:给模型套一层后处理描边,出来的效果像是"塑料模型外面糊了一圈黑胶带"。线是等宽的、死板的、贴着屏幕走的,跟漫画里那种有轻重、有呼吸、会在转角处断开的手绘线完全是两回事。

Moebius(Jean Giraud)之所以被无数 CG 从业者当成临摹对象,不是因为他的线多复杂,恰恰是因为他的线极简——但极简的线条对"节奏感"要求反而更高。这一点直接决定了:用 Sobel 复刻 Moebius 画风,真正难的从来不是 Sobel 卷积本身,那只是九次采样加两次乘加。真正难的是线从哪里来、怎么变粗变细、怎么抖动得自然、颜色怎么被压平成色块。

这篇文章会沿着一条完整的工程链路走一遍:先把画风拆成可编程的特征,再设计数据缓冲,然后在 Unity URP 里写后处理 Shader,最后给出一个用 OpenCV 离线调参的脚本——因为在引擎里反复改参数试错太慢了,先用静态图把阈值调准,再搬进实时管线,效率会高很多。读完你应该能得到一套可运行的实时 3D 风格化渲染方案,以及一份"哪一步最容易翻车"的排查清单。

1. 先泼一盆冷水:Sobel 只是这套画风里最便宜的一环

很多教程把"卡通描边"简化成一句话:拿深度图跑个 Sobel,超过阈值的地方涂黑。这一步确实五分钟能跑通,但它产出的东西离 Moebius 很远。原因很直接:Sobel 给出的是"梯度幅值",是一个连续量;而手绘线是一种有意图的标记,画师知道哪里该重、哪里该轻、哪里该留白。

如果你只做"梯度大于阈值就涂黑",会立刻遇到三个问题。第一,线和背景是硬切的二值关系,没有过渡和粗细变化,视觉上像贴纸。第二,深度梯度对掠射角极度敏感,地面向远处延伸时梯度会持续偏高,结果地平线附近糊成一条粗黑带。第三,法线梯度在曲面细分不足的低模上会出现锯齿状的断续线,看起来像显卡坏了。

所以这套方案的设计思路是:Sobel / Roberts 只负责"候选边缘",后面还有三道工序在决定最终长什么样——线宽随深度缩放、采样坐标做手绘抖动、边缘强度做距离衰减。这三步加起来的工作量比 Sobel 本身还大,但它们才是决定"像不像"的地方。

另一个需要提前说清的判断:这套管线是纯后处理,不修改材质、不写 Stencil、不需要美术改模型。好处是接入成本极低,任何已有场景挂上就能看效果;代价是它只能画"几何轮廓和深度断层",画不出画师那种主动添加的装饰线、排线阴影和构图引导线。如果你的目标是"漫画感",够用;如果你的目标是"精确复刻某一格分镜",那需要的不只是后处理,还要动画和美术配合。这个边界最好一开始就想明白。

2. Moebius 画风的四个可编程特征

把一种画风拆成 GPU 能理解的东西,是这类实验最关键的一步。拆得太细会陷入无限的调参,拆得太粗又做不出辨识度。经过取舍,我认为有四个特征值得并且能够在实时管线里近似。

2.1 线条:等宽描边是最容易露馅的地方

Moebius 的线有明显的粗细变化:靠近视觉中心、结构转折处、物体靠近镜头的地方,线会加粗;远景、次要结构、受光面边缘,线会变细甚至消失。这种变化不是随机的,它服务于"哪里是重点"这个叙事目的。

在后处理里我们没法知道叙事重点,但可以用两个物理量去逼近:深度和边缘强度。深度越近线越粗,这是透视的自然结果;边缘强度越大线越黑,因为强度大意味着结构转折剧烈。这两条规则加起来,就能让线产生最基本的轻重感。

另一个容易被忽略的点是线条的连续性。真实手绘线在转角处会有粗细突变,在长直边上会有轻微的弯曲。如果我们的线是逐像素独立计算的,就会得到一条数学上完美、视觉上僵硬的等值线。所以要引入采样坐标的扰动,让边缘检测的"取样窗口"沿着一条缓慢变化的曲线滑动。

2.2 色块:把渐变压成台阶

Moebius 上色极少用连续渐变,大量使用平涂色块。这一点在实时渲染里反而是最容易实现的——做个色阶量化(posterize)就行,公式就是floor(c * N + 0.5) / N。但直接量化会出问题:如果 N 太小,暗部会彻底丢失层次,变成一块死黑;如果 N 太大,又看不出块面感。

实践中比较稳的做法是给 N 取 4 到 6 之间,并且在量化前先做一次轻微的对比度拉伸。原因是大部分实时场景的直方图集中在中间调,直接量化后台阶会挤在一起,看起来像色带而不是色块。另外要注意量化必须放在色调映射之后,否则 HDR 的高光部分会被压到一起,出现难看的色阶断层。

2.3 留白与纸张:画面需要"呼吸"

这是最容易被跳过、但对最终观感影响极大的一层。纯数字渲染的画面是"无缝"的,每个像素都被填满了信息;而纸媒作品天然带纸纹、油墨渗透和轻微的色彩偏移。把纸色(偏暖的米白)乘到量化后的颜色上,再叠一层低强度的高频噪声,画面的"廉价感"会立刻下降一个档次。

要注意的是纸色不能直接用纯白。用纯白做底会让整个画面偏冷偏硬,用(0.96, 0.93, 0.85)这种带一点黄的低饱和色,跟墨线搭配起来更接近印刷品。这个数值不是玄学,本质上是给整幅画面加了一层极淡的暖色滤镜,模仿纸张对短波长的吸收。

2.4 哪些能做,哪些做不了

诚实地划一下边界。能做:几何轮廓线、折角线、深度断层线、线宽随距离变化、手绘抖动、色阶量化、纸张颗粒。做不了:主动添加的装饰排线、大面积留白构图、物体内部的纹理细节、基于叙事的线条省略。

如果项目要求更高,下一步不是继续堆后处理参数,而是引入材质侧的贡献——比如用一张手绘纹理去调制线宽,或者给特定材质打标签让它在边缘检测里"隐身"。那已经是另一个工程量级了,后处理只适合作为第一层基础。

3. 数据准备:为什么深度 + 法线缺一不可

决定这套方案上限的,其实是你能拿到几张缓冲。很多人一开始只用颜色图做边缘检测,结果线条又糊又乱,因为颜色变化不代表结构变化——贴图上的花纹也会被当成边缘。

3.1 深度缓冲:轮廓的骨架

深度缓冲提供的是"物体与相机之间的距离"。在两个物体的交界处,深度会发生跳变,这个跳变就是轮廓线。深度边缘的最大优势是它不关心物体长什么样,只要遮挡关系存在,线就存在,非常适合画剪影。

但深度边缘有两个典型问题。一是前面提到的掠射角问题:一个平面向远处延伸时,相邻像素的深度差会持续增大,导致梯度偏高,形成假边缘。解决办法是把梯度相对于中心深度做归一化,也就是用"相对深度变化率"而不是"绝对深度差"来判定。二是天空盒问题:天空区域采样到的是远平面深度,与几何交界处会产生一条极粗的线。

3.2 法线缓冲:折角处的转折线

法线缓冲记录的是每个像素的表面朝向。当表面朝向发生突变时(比如立方体的棱边),法线之间会产生大的角度差,这就是折角线。它补上了深度缓冲的短板:两个紧贴在一起的共面物体,深度上几乎没差别,但法线可能完全不同。

法线边缘的判定通常不直接用 Sobel,而是用 Roberts 交叉算子,采样四个对角邻居。原因是 Roberts 用的是对角差分,对斜向的边缘响应更均衡,而且只需要四次采样,比九次采样的 Sobel 便宜一半多。对法线这种三分量数据,采样成本是实打实的。

3.3 颜色缓冲:最终上色的底

颜色缓冲这一层的作用不是检测边缘,而是提供量化前的原始颜色。把量化后的颜色和边缘遮罩做一次 lerp,就得到了"有色块、有线条"的成品。注意这里采样的是后处理链上游的结果,如果管线里还有别的后处理(比如 Bloom),需要明确它在这套描边之前还是之后执行,顺序不同效果差别很大。

缓冲主要贡献典型失效场景补偿手段
深度轮廓、遮挡边界掠射角假边缘、天空交界相对深度归一化、距离衰减
法线折角线、硬边低模曲面锯齿、法线未开平滑法线、开启法线纹理
颜色色块量化底图贴图纹理被误判为边缘不参与边缘检测,只做量化

4. 环境准备与工程搭建

4.1 环境清单

这套实现基于 Unity 的通用渲染管线(URP),因为它的后处理扩展点相对清晰。下面列出的是各个组件的作用,具体版本请以你实际项目的版本为准,本文重点演示的是通用思路,不同 URP 版本在 API 签名上会有差异。

组件作用备注
Unity 编辑器主工程环境URP 12/14 系列写法接近,URP 17 起 Pass API 有调整
URP Asset提供深度与法线纹理需确认 Depth Texture 与 Opaque Texture 选项
Shader边缘检测与合成全屏 Pass,HLSL 编写
ScriptableRendererFeature把 Pass 插入渲染链负责申请临时 RT 和调度 Blit
后处理材质承载 Shader 参数在 Inspector 中暴露阈值、线宽等

需要特别提醒的一点:必须确保 URP Asset 里开启深度纹理。如果你的场景用了自定义的 Renderer Data,也要在对应位置确认。很多人第一次跑出来是纯黑或者完全没线,八成就是这里没开。另外法线纹理在某些平台(尤其是移动端的部分 GPU)上开销不低,如果目标平台性能紧张,可以考虑用深度重建法线来替代,代价是精度略差。

4.2 场景与相机配置

场景本身没有特殊要求,但为了让边缘检测有东西可抓,建议先用几何体搭一个测试场景:几个不同朝向的立方体、一个球、一段地面。球用来验证曲面法线的连续性,立方体验证硬边,地面验证掠射角假边缘——这三样凑齐,参数调起来才有参照。

相机设置上有两个点值得注意。第一,关闭 MSAA 或者确认它不会影响深度采样,某些配置下多重采样会让深度边缘出现半像素偏移,线条会变虚。第二,确认色彩空间。线性空间下做色阶量化,得到的台阶分布和伽马空间完全不同,如果发现量化后的画面偏灰,先检查这里。

4.3 后处理材质与 Feature 挂载

创建材质时,Shader 选择我们下面要写的自定义 Shader,然后把材质拖到 Renderer Feature 的槽位里。Feature 的插入时机建议放在AfterRenderingPostProcessing,这样描边会盖在所有颜色处理之上,线条颜色不会被 Bloom 之类的效果污染。如果你的项目需要在描边之后再叠一层别的特效,那就调整到这个特效之前。

5. 核心 Shader:Sobel 与 Roberts 的混合边缘检测

5.1 采样与线性化深度

深度纹理里存的是非线性深度(受投影矩阵影响),直接拿来做差分会得到严重失真的结果——近处的深度差被放大,远处的被压缩。所以第一步必须线性化。URP 提供了现成的函数,配合_ZBufferParams就能把设备深度转成视空间下的线性距离。

float LinearEyeDepthFromUV(float2 uv) { float raw = SampleSceneDepth(uv); return LinearEyeDepth(raw, _ZBufferParams); }

这里SampleSceneDepth来自DeclareDepthTexture.hlsl,LinearEyeDepth来自核心库,两者在 URP 的 ShaderLibrary 里都是标准接口。线性化之后的深度单位是"米",可以直接用来做距离衰减和线宽缩放,这也是后面几步能成立的前提。

5.2 两个算子为什么分工

Sobel 是 3×3 的加权差分,中心权重高、边缘权重低,对噪声有一定抑制,适合处理深度这种可能带轻微噪点的数据。Roberts 是 2×2 的对角差分,只采四个点,对角度变化更敏感,适合处理法线这种干净但需要精确角度响应的数据。

所以这套方案的分工是:深度走 Sobel,法线走 Roberts。这样既保证了深度边缘的稳定性,又控制了法线采样的开销。如果两个都用 Sobel,法线那部分要多采五次,在移动端是能感知到的开销;如果两个都用 Roberts,深度边缘会因为噪声出现断点。

5.3 边缘强度归一化

这是我在调试中认为最关键的一步。原始梯度幅值和场景尺度强相关——一个 1 米见方的场景和一个 100 米见方的场景,同样的阈值下表现完全不同。解决办法是除以中心深度:

float depthEdge = length(grad) / max(dCenter, 0.001); float depthMask = smoothstep(_DepthThreshold, _DepthThreshold * 2.0, depthEdge);

除以中心深度后得到的是"相对深度变化率",量纲被消掉了,阈值就变成了一个和场景尺度无关的比例值。用smoothstep而不是step,是为了让线边缘有半像素级的过渡,避免高对比场景下的锯齿。

法线那边不需要归一化,因为法线本身已经是单位向量,差值范围固定在[0, 2]之间,阈值可以直接给绝对值。

6. 从"边缘检测"到"手绘线":抖动、线宽、量化

6.1 采样坐标扰动

手绘感的核心在于"不精确"。如果边缘检测的采样窗口严格对齐像素网格,得到的线就是数学意义上的等值线,完美但冰冷。做法是在采样前给 UV 加一个低频偏移:

float2 wobble = float2( sin(uv.y * _WobbleFreq + _Time.y * _WobbleSpeed), cos(uv.x * _WobbleFreq * 1.3 + _Time.y * _WobbleSpeed * 0.7) ) * (_WobbleAmp * texel); float2 cuv = uv + wobble;

两个方向的频率用了不同系数(1.3 和 0.7),是为了避免出现明显的网格状规律。偏移量乘texel是为了让抖动的物理幅度固定在几个像素,不随分辨率变化。

这里有一个取舍:带_Time的抖动会产生"沸腾"效果,画面看起来像手绘动画逐帧重画,很有味道,但如果速度太快会让人眼疲劳。静态插画风格建议把_WobbleSpeed调到 0 或者接近 0,只在 UV 上加低频噪声,得到固定的"手抖"。

6.2 世界尺度线宽

固定屏幕空间的线宽在近处看起来太细、远处看起来太粗,违背透视直觉。解决办法是让采样步长随深度放大:

float widthScale = max(dCenter / max(_WidthRefDist, 0.001), 0.35); float2 stepUV = texel * _LineWidth * widthScale;

_WidthRefDist是"线宽等于基准值的距离",0.35是下限,防止极近处的步长过小导致采样不到边缘。这个设计让线宽在透视上更自然,同时也带来一个副作用:远处的线会变粗到互相粘连,所以必须配合距离衰减把远处的线淡出。

6.3 Posterize 与纸张噪声

色阶量化前面说过,_PosterizeSteps建议从 5 开始试。纸张噪声用经典的 hash 函数生成就行,不需要贴图:

float2 puv = uv * _ScreenParams.xy; float grain = frac(sin(dot(puv, float2(12.9898, 78.233))) * 43758.5453); quant *= lerp(1.0, 0.92 + grain * 0.16, _PaperStrength);

用屏幕坐标而不是 UV 作为 hash 输入,是为了让颗粒固定在世界空间中不随相机移动——如果颗粒跟着屏幕走,相机一动整个画面会像有层油膜在滑动,非常出戏。另外_PaperStrength建议不要超过 0.15,太高会让画面显脏。

7. 完整代码实现

7.1 HLSL Shader 完整代码

文件路径:Assets/Shaders/MoebiusSobelOutline.shader

Shader "Moebius/SobelOutline" { Properties { _LineColor ("墨水颜色", Color) = (0.08, 0.07, 0.06, 1) _PaperColor ("纸色", Color) = (0.96, 0.93, 0.85, 1) _DepthThreshold ("深度边缘阈值", Range(0.001, 0.5)) = 0.03 _NormalThreshold ("法线边缘阈值", Range(0.01, 2.0)) = 0.5 _LineWidth ("线宽系数", Range(0.2, 6.0)) = 1.5 _WidthRefDist ("线宽参考距离", Float) = 10.0 _DistFade ("线条远距衰减", Float) = 120.0 _WobbleAmp ("抖动幅度(px)", Range(0.0, 4.0)) = 0.7 _WobbleFreq ("抖动频率", Float) = 26.0 _WobbleSpeed ("抖动速度", Float) = 0.8 _PosterizeSteps ("色阶数", Range(2.0, 16.0)) = 5.0 _PaperStrength ("纸张颗粒强度", Range(0.0, 0.5)) = 0.10 } SubShader { Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" } ZTest Always ZWrite Off Cull Off Pass { Name "MoebiusOutline" HLSLPROGRAM #pragma vertex Vert #pragma fragment Frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareNormalsTexture.hlsl" // 后处理链上游的颜色缓冲,由 Blitter.BlitCameraTexture 绑定 TEXTURE2D_X(_BlitTexture); CBUFFER_START(UnityPerMaterial) float4 _LineColor; float4 _PaperColor; float _DepthThreshold; float _NormalThreshold; float _LineWidth; float _WidthRefDist; float _DistFade; float _WobbleAmp; float _WobbleFreq; float _WobbleSpeed; float _PosterizeSteps; float _PaperStrength; CBUFFER_END static const float2 kOffsets[9] = { float2(-1, -1), float2(0, -1), float2(1, -1), float2(-1, 0), float2(0, 0), float2(1, 0), float2(-1, 1), float2(0, 1), float2(1, 1) }; static const float kSobelX[9] = { -1, 0, 1, -2, 0, 2, -1, 0, 1 }; static const float kSobelY[9] = { -1, -2, -1, 0, 0, 0, 1, 2, 1 }; struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings Vert(Attributes input) { Varyings o; o.positionCS = TransformObjectToHClip(input.positionOS.xyz); o.uv = input.uv; return o; } float LinearEyeDepthFromUV(float2 uv) { float raw = SampleSceneDepth(uv); return LinearEyeDepth(raw, _ZBufferParams); } float4 Frag(Varyings input) : SV_Target { float2 uv = input.uv; float2 texel = 1.0 / _ScreenParams.xy; // 1. 手绘抖动:扰动采样中心 float2 wobble = float2( sin(uv.y * _WobbleFreq + _Time.y * _WobbleSpeed), cos(uv.x * _WobbleFreq * 1.3 + _Time.y * _WobbleSpeed * 0.7) ) * (_WobbleAmp * tex
返回列表