StrokeGen实战:GPU实时高质量卡通描边与笔画生成管线 1. 为什么卡渲描边是个“看起来简单、做起来头大”的活先聊个现象。我接触过不少刚入行做卡通渲染的同学第一反应都是“描边嘛边缘检测或者反向膨胀随便搞搞就能出效果”。可真把项目跑起来发现根本不是这么回事线条要么断得一截一截要么在镜头快速移动时糊成一片拖影要么模型一换就出现一条条莫名奇妙的噪声线。最后被美术一句“这线有点脏”打回重做心态直接炸裂。这正是我想写StrokeGen这套方案的直接原因。它的本质是把“描边”这件事从传统的边缘检测思路升级成一套GPU实时、高质量、风格化可控的笔画生成管线。摘开说就是靠GPU的并行能力和精心设计的多通道策略让描边既能实时跑又具备漫画/动画质感还允许调参控制“这笔刷到底有多粗、多硬、多连贯”。这篇内容更适合这几类人看正在做卡通渲染项目的TA技术美术、Unity/UE里写Shader的程序员、对NPR非真实感渲染感兴趣的图形学爱好者。当然如果你只是想知道“为什么我的描边总拖影”“怎么提高描边的稳定性”这篇也会给出非常实在的排查思路。我不想讲那些理论堆满却落不了地的概念。下面所有内容基本都会沿着“为什么要这么设计—到底怎么写—遇到什么坑怎么解决”这个线索来走。你可以把它当成一篇偏实战的踩坑记录来看也可以当作一个可以直接往自己项目里搬的参考实现。2. StrokeGen的设计思路从“描边”到“笔画生成”的思维转换2.1 传统边缘检测为何在实时渲染里不够用先给没怎么接触过NPR描边的朋友补个底。目前主流的卡渲描边大体分三类几何描边在模型背面做反向膨胀把背面放大一圈当描边最早最常见优点是干净锐利缺点是需要额外Pass、对硬表面模型效果好但对面片、植被“不友好”。屏幕后处理描边用深度、法线、颜色等输入做边缘检测识别出边界然后上色。优点是全屏一把梭灵活性高缺点是线条容易“爬动”而且相邻像素识别不稳定会带来时间上的闪烁。扩展材质描边把描边信息写进模型顶点色、UV或者独立贴图里靠美术手工标记质量最高但耗时也最大。我做过几个项目以后发现传统方案最大的问题不是“画不出边”而是“边不够像一笔画出来的”。什么叫“像一个笔画”用人话说一条好描边要有几个特征连续、粗细相对稳定、转角处不“断气”、在模型和背景之间能自然过渡、运动时不会产生让人晕眩的残影。而深度边缘检测这类方案天然只关心“哪里是边界”压根不关心“这条边界应该长成什么形态”所以它的结果往往是一堆离散的、粗细不均的小段。到这一步就引出了StrokeGen的一个核心转变我们不只做边缘检测而是把边缘检测的结果当作输入再走一遍“笔画生成”的逻辑。2.2 StrokeGen到底在“生成”什么单从名字拆StrokeGen就是Stroke笔画 Gen生成所以它不是一套简单的描边滤镜。它的目的是在实时渲染里生成一条条结构清晰、方向明确、可被参数控制的虚拟笔画。我理解它的关键点有三层。第一层对边缘做“方向场”的估算。我们不只是想知道某像素是不是边缘还要知道这条边缘在这个像素周围是往哪个方向延伸的。方向的估算可以让后续线条的连续性大幅提升也可以有效抑制“线条乱跳”。第二层对边缘做“区域化”处理。传统描边是逐像素独立判断StrokeGen则会参考邻域范围内的法线、深度、BaseColor等特征让一条边尽量保持整体一致。这样是避免“同一个物体的外轮廓一半粗一半细”这个尴尬问题的关键。第三层把描边结果参数化。比如线条的软硬程度、粗细衰减、与主光源的联动、贴近阴影区的变色等都可以暴露成参数给美术调节。这是从“能看”到“能用”的分水岭。你可能已经感觉到了这套思路放到GPU上是有天然优势的因为这些计算基本都是逐像素、可并行的。而且现代图形API里的Compute Shader和异步计算刚好像是为这种全屏处理准备的。2.3 为什么GPU实时处理在方案里这么关键如果只是做离线渲染描边想多高质量都可以慢慢调就是了。但实时渲染不一样。尤其是游戏、VTube、影视预演这类场景一秒钟要输出几十上百帧每帧还有主场景、阴影、后处理等一大票东西抢GPU资源。StrokeGen把大量计算搬到GPU上以后就能做到几个特别实际的效果不额外占用CPU不会拖慢游戏主线程尤其在主机和移动端这些CPU本来就吃紧的平台上优势明显。可以实时随着相机视角、模型变形、表情动画变化而更新描边不会出现“线跟模型脱节”的尴尬。后处理管线天然适合全屏操作的执行模型只需要在屏幕空间拿到深度、法线等数据就能在同一帧内完成描边的检测、生成与合成基本不需要反复读写显存。我自己的体会是只要条件允许描边逻辑尽量往屏幕后处理阶段平移。这样不依赖具体模型怎么建模只要最后的法线、深度数据能合出来全屏就都能覆盖到。可如果你走纯几何描边路线骨骼动画、布料模拟和Morph Target这些一掺和进来很容易出现描边穿帮。3. 实操指南搭建一套GPU实时高质量描边管线3.1 管线的输入准备从G-Buffer里抓“可用特征”想实现StrokeGen级别的描边第一步不是直接上边缘检测而是先准备一份“数据足够干净”的缓冲区。一般情况下我们最需要的是以下几种信息场景深度Depth用来做视野内的几何轮廓检测也能判断物体前后的遮挡关系。世界空间法线World Normal用来识别模型表面朝向的突变比如棱角、接缝。基础色/材质IDBaseColor/MaterialID用来在颜色或材质差异很大的边缘处补线。物体运动矢量Motion Vector虽然不是描边必须项但在处理拖影问题时能帮上大忙后面我会具体讲。如果你的项目是Unity加上URP管线那G-Buffer里其实已经包含大部分数据。如果是自定义管线你需要在渲染主场景的同时把这些特征单独输出到一张R8G8B8A8或者R16G16B16A16的RenderTexture上。一个非常实用的建议是不要把原始法线和深度直接拿来做描边先做一个“去噪/平滑”预处理。因为很多闪烁和毛刺的表现都来自原始深度在物体边缘的锯齿、法线在凹凸细节上的高频变化。预处理这一步可以用一个简单的3x3或5x5空间滤波完成成本极低改观却很明显。3.2 核心实现从边缘检测到“笔画生成”的关键步骤接下来直接上实现。为了照顾不同引擎的同学我这里用类似HLSL的写法里面只依赖最基础的纹理采样语法不依赖某个引擎的特殊函数库。第一步重建屏幕空间坐标和法线。如果用的是Unity内置函数会直接给到这些值其他引擎也基本有大同小异的函数。// 从深度纹理重建世界坐标 float3 reconstructWorldPos(float2 uv, float depth) { float4 ndc float4(uv * 2.0 - 1.0, depth, 1.0); #if UNITY_UV_STARTS_AT_TOP ndc.y -ndc.y; #endif float4 worldPos mul(_InverseViewProjection, ndc); return worldPos.xyz / worldPos.w; }第二步做边缘强度的计算。我这里是综合了三种特征深度差、法线差、颜色BaseColor差。具体做法是采样当前像素周围四个方向上下左右或者八个方向的邻域计算带权重的差异值。float4 edgeDetect(float2 uv, float2 texelSize) { float centerDepth tex2D(_CameraDepthTexture, uv).r; float3 centerNormal tex2D(_CameraNormalsTexture, uv).xyz; float3 centerColor tex2D(_MainTex, uv).rgb; float depthThreshold 0.1; // 深度差阈值视场景微调 float normalThreshold 0.6; // 法线夹角阈值可暴露成参数 float colorThreshold 0.15; // 颜色差阈值美术可调 float edge 0.0; // 只演示上下左右四个方向实际可扩展到八方向 for (int i 0; i 4; i) { float2 offset _Offset[i] * texelSize; float2 sampleUV uv offset; float sampleDepth tex2D(_CameraDepthTexture, sampleUV).r; float3 sampleNormal tex2D(_CameraNormalsTexture, sampleUV).xyz; float3 sampleColor tex2D(_MainTex, sampleUV).rgb; // 深度差异 float depthDiff abs(centerDepth - sampleDepth); // 法线差异用夹角余弦判断 float normalDot dot(centerNormal, sampleNormal); float normalDiff 1.0 - saturate(normalDot); // 颜色差异 float colorDiff distance(centerColor, sampleColor); // 三种特征加权求和 edge smoothstep(depthThreshold, depthThreshold * 2.0, depthDiff); edge smoothstep(normalThreshold, normalThreshold * 1.4, normalDiff) * 0.8; edge smoothstep(colorThreshold, colorThreshold * 1.5, colorDiff) * 0.6; } return edge / 4.0; }这一步做完边缘强度图已经出来了。但请注意如果你直接拿着这张图来上色大概率会得到一条条“断断续续、明暗不均”的线。原因很简单相邻像素各自独立做了判断噪声和采样偏差被放大了。所以第三步也就是StrokeGen味道最浓的一步让边缘“成长”为笔画。我们需要对边缘强度图做两件事一是方向性模糊Directional Blur二是沿边缘方向做连续性强化Upsampling / Morphological Smooth。方向性模糊的核心思路是先估算当前边缘的大致走向然后沿着这条走向做模糊而不是像普通高斯模糊那样半径均匀散开。这样既抹平了断裂感又不会把线条糊成一团。方向估算有很多种算法我通常用Sobel算子先在边缘强度图上算出梯度方向然后取垂直方向作为边缘走向。float2 sobelGradient(float2 uv, float2 texelSize) { float c tex2D(_EdgeTex, uv).r; float tl tex2D(_EdgeTex, uv float2(-texelSize.x, -texelSize.y)).r; float tr tex2D(_EdgeTex, uv float2( texelSize.x, -texelSize.y)).r; float bl tex2D(_EdgeTex, uv float2(-texelSize.x, texelSize.y)).r; float br tex2D(_EdgeTex, uv float2( texelSize.x, texelSize.y)).r; float gx (tr 2.0 * tex2D(_EdgeTex, uv float2(texelSize.x, 0)).r br) - (tl 2.0 * tex2D(_EdgeTex, uv float2(-texelSize.x, 0)).r bl); float gy (bl 2.0 * tex2D(_EdgeTex, uv float2(0, texelSize.y)).r br) - (tl 2.0 * tex2D(_EdgeTex, uv float2(0, -texelSize.y)).r tr); return normalize(float2(gx, gy)); }拿到梯度方向之后边缘走向就是垂直方向。接着做方向性采样把沿线方向上的边缘强度累加过来让断线“接”上。还有一个我强烈建议加进实现的东西亚像素精度。后处理方案最容易被人诟病的一点是锯齿尤其是线条边缘。解决办法是在最终合成描边颜色时对边缘强度做smoothstep让边缘有0.5~1像素的过渡。视觉上会非常明显地柔和下来。3.3 风格化参数设计与调整方法StrokeGen真正比普通描边方案灵活的地方是它把“风格”放到了参数层。下面是我实测下来最常用的一套参数直接列成表格方便你照抄参考。参数名作用范围推荐范围调整说明Outline Width描边宽度整体线宽0.5~3像素决定了线条的基础粗细数值过大会让近景看起来“黏糊糊”Edge Softness边缘软化线条内外过渡0.2~1.0控制线条边缘的羽化程度越高越柔和越低越硬朗Depth Bias深度阈值深度检测灵敏度0.05~0.3调高则忽略微小的深度差减少地面褶皱、衣物起伏处的杂乱线条Normal Bias法线阈值法线检测灵敏度0.4~0.8调高则只在折角明显的结构上画线适合风格化比较“干净”的模型Color Bias颜色阈值颜色检测灵敏度0.1~0.3当模型贴图色块边界也想出线时调低纯卡通平涂可以适当调高Stroke Smooth Iterations笔画平滑迭代数断线修补程度0~3迭代越多线越连贯但性能开销递增一般1~2次足够Flow Intensity方向流动强度描边方向感0~1.0控制沿边缘方向的采样强度类似给描边加“笔锋”效果以我自己的项目为例做偏“动漫赛璐璐”风格时我习惯把Normal Bias打到0.7以上Color Bias压到0.2以下这样既能保留头发、脸部轮廓这些关键结构线又不会在鼻梁和嘴唇这种微折角上“炸出”一堆细碎线条。而做偏“欧美卡通/Graphic Novel”风格时我会反过来把Depth Bias调低让物体与物体交叠的边缘更敏感同时把Edge Softness调到接近0.8得到一条粗厚但是带有手绘晕染感的描边。你可能会疑惑这些参数具体怎么映射到代码里的实际就是把上一步计算的edge值与这些阈值比较再用smoothstep插值最后乘一个可调颜色或者采样纹理作为描边色输出。我后面会给出一个完整的Frag函数示例。3.4 完整合成阶段把描边和主画面叠到一起前面几步产出的是一张边缘强度图这时候还差最后一步把描边画到主画面上去。这里有两种常见做法。第一种是“描边叠加”在Post Process的最终Pass里直接用描边颜色叠加覆盖。这种做法适合描边完全遮挡底色的风格比如赛璐璐外轮廓。做法很简单拿到描边强度后用它作为alpha在描边色和原图之间插值。float4 frag(Varyings input) : SV_Target { float2 uv input.uv; float edge tex2D(_EdgeTex, uv).r; // 扫掉极微小的噪声 edge smoothstep(_EdgeThreshold, _EdgeThreshold _EdgeSoftness, edge); // 描边色和原图叠加 float3 baseColor tex2D(_MainTex, uv).rgb; float3 outlineColor _OutlineColor.rgb; float3 finalColor lerp(baseColor, outlineColor, edge * _OutlineAlpha); return float4(finalColor, 1.0); }第二种是“描边混合”边缘强度仅仅作为一个权重叠加在原始画面之上保留部分底色细节。这种适合描边比较轻量的风格。美术一般会在“描边透明度”和“笔画内阴影”之间做很多微调。如果项目里用到了URP或HDRP你可以在Blit Renderer Feature里挂这一步。如果是旧版Built-in管线那就是往OnRenderImage里塞。无论走哪种请一定注意把描边Pass放在后处理链的合适位置。我的习惯是放在色调映射之前这样描边既能受到后期调色的影响又不会被Bloom糊掉太多。4. 常见问题与排查技巧实录4.1 描边拖影问题到底怎么治“描边拖影”这四个字我几乎每隔几天就会在技术群里看到一次。这里的拖影分两种表现一种是静态帧里线条边上一圈模糊残影另一种是动态场景里线条根部拖出长长的尾迹。先说第一种静态拖影。排查思路其实很简单残影通常来自“过宽的采样范围”和“不恰当的时间累积”。你要检查是不是在后处理时用了太大的模糊半径尤其是方向性模糊半径设置过高会把线条边缘“撑”成了一片渐变。这个好解决把方向滤波的采样半径降下来或者把采样次数从8降到4线条的锐度就回来了。再说第二种动态拖影。这里十有八九是**时间性抗锯齿TAA**的锅。TAA会根据Motion Vector把历史帧像素混合进来但如果描边像素和主场景像素的运动矢量不一致历史帧里的描边就和当前帧的描边错位形成重影。解决办法有几种把描边Pass放到TAA之后或者把描边强度图也写一份Motion Vector给TAA让它能把描边和画面一起混合。把描边输出到单独的RT不参与TAA累积最后再叠加到主画面。如果描边的运动矢量不好生成折中做法是降低TAA的历史帧权重用多点采样来保证稳定。我实测下来最稳定的做法是“描边不参与TAA的累积只在最后合成阶段和主画面融合”。这个方案的代价是抗锯齿后的描边边缘可能会出现一点阶梯感所以描边本身的亚像素平滑一定要做好。4.2 GPU状态下画面崩溃或D3D设备已移除热词里提到Unity卡渲Shader时总会有人遇到“GPU发生崩溃或D3D设备已移除”Device Removed的报错。这个往往不是Shader逻辑本身的锅而是一只脚本通过后处理每帧申请了过大的RenderTexture导致显存瞬间峰值或者Pass里出现了除以零、无限循环等让GPU无法恢复的异常。我排查这类问题的通用路径先用RenderDoc或PIX逐帧抓取看崩溃发生在哪个Pass、哪次DrawCall。如果抓不到就先关掉后处理和描边再逐个Pass打开做二分定位。检查RenderTexture的格式和尺寸确保是2的幂次或与屏幕尺寸匹配。不要每帧都新建RT尽量复用临时RT用GetTemporaryRT也要记得Release。检查Shader里的for循环。GPU的循环次数必须是编译期可确定的否则会出现极长的循环导致看门狗超时。在方向性模糊里采样次数最好用[unroll]展开或者用定长循环。检查是否存在NaN或Inf。极端参数下normalize()一个零向量会产生NaN一旦NaN进入纹理采样结果不可控严重时会导致驱动层崩溃。所以在Sobel方向估算那一步一定要加一个保护向量长度过小时直接返回float2(1,0)。这些都是很典型的实战坑写出来是想让大家知道GPU崩溃不一定是你代码写的“不对”更多时候是“非法操作”触发了驱动的保护机制。稳住心态按步骤排查就好。4.3 描边断裂、闪烁与“线条一会粗一会细”这类问题在动态场景里尤其明显。根本原因是许多边缘检测是“像素级独立判断”缺少全局一致性。StrokeGen的思路是尽量让线条拥有“区域连续性”。排查时我建议按顺序检查下列几点边缘强度图是否出现了大量孤立高亮点如果是说明当前阈值的灵敏度太高先调Bias。方向性模糊的迭代次数是否足够对于长时间持续闪烁的线条把Stroke Smooth Iterations提到2或3通常会有质的改善。我们在屏幕空间做后处理描边但模型在屏幕外的边缘变化剧烈尤其是镜头旋转时屏幕空间边缘会一帧一帧地“爬”过像素。不要试图完全消除这种爬动更务实的是通过加入时间滤波比如用时间上的指数加权移动平均让线条过渡平滑。关于“线条一会粗一会细”还有一个往往被忽略的原因描边宽度没有根据屏幕分辨率做缩放。同一根线在1080p下占3个像素在4K下还是3个像素看起来就会“细”不少。所以建议把Width参数乘上一个分辨率归一化系数float normalizedWidth _OutlineWidth * (1080.0 / _ScreenParams.y);这样不同分辨率下的视觉宽度能保持基本一致。这个细节美术很少主动提但给到项目里是真香。4.4 调试与性能调优的独家心得这部分分享几个我实测下来很管用的调试技巧。第一描边强度图可视化。在做后处理时不要把描边结果直接叠加到画面里调试先在Debug模式下把边缘强度图单独输出为一张灰度图。这样你能很直观地看到哪些线条过强、哪些断裂。等到边缘强度图看起来干净了再打开合成关卡。第二性能预算控制在1毫秒以内。屏幕空间的描边在1080p下我建议全屏处理控制在0.5~1ms左右中端显卡。超过这个数就要看看是不是采样次数太多了。一般来说四方向采样加1次方向性模糊就够用八方向采样一般用于特别追求质量的场景。在移动端我甚至建议把方向性模糊砍掉直接用四方向采样加上一个低成本的抖动Dithering来模拟柔和过渡。第三异步计算有一定的优化空间。如果描边不依赖后处理链里某些中间结果可以考虑放到异步Compute Queue里跑。不过异步计算在移动端尤其部分Adreno和Mali GPU兼容性不稳定要用的话提前做真机验证。第四把描边参数全部暴露成材质属性并分类做好分组。美术调参时会非常感激你。比如可以按“宽度组”“阈值组”“颜色组”“方向处理组”来划分命名也尽量用Outline_Width这种有前缀的结构方便在Shader里快速检索。5. 关于GPU驱动、平台适配与后续扩展的几点经验5.1 平台差异PC、主机和移动端的处理差异写GPU描边Shader时最怕的就是“PC上好好的一到手机上就看不清或者闪成鬼片”。原因主要有三精度、带宽和API差异。精度方面PC桌面端通常用FP32移动端很多GPU为了性能会把中精度FP16算得飞快。遇到深度差值很小的情况FP16的精度不够容易导致边缘检测判断抖动。我的建议是在移动端做深度差值时先对深度做一次log变换拉大近距离细节的差异再算阈值这样能有效降低精度问题。也可以直接在Shader里用min16float来标注允许半精度让编译器自己优化。带宽方面后处理描边需要读写多张全屏贴图这在移动端非常吃带宽。优化思路是把深度、法线、颜色压到同一张RT的各个通道里。比如用R通道存深度、G和B通道存压缩后的法线、A通道存材质ID这样屏幕后处理只用绑定一张纹理带宽开销直接砍半。API差异方面Unity里要留意半像素偏移。在D3D平台纹理坐标原点通常在上方OpenGL/Vulkan则在下方如果后处理uv处理不当会出现上下颠倒或者边缘错位的问题。解决办法是判断UNITY_UV_STARTS_AT_TOP宏并对uv做相应翻转。这段代码我在前面重建世界坐标时已经写了实际项目里也要在采样屏幕纹理前套用类似的兼容逻辑。5.2 与描边相关的GPU驱动兼容性认知很多同学一碰到兼容性Bug就慌其实这里面有一部分是驱动本身的毛病不是我们代码的问题。就比如不同版本NVIDIA驱动对同一段Demote指令的处理有差异导致棋盘格采样出来的结果不同。经验是尽量别用太冷门的纹理格式别依赖未定义行为。比如对同一样本点做非连续的Gather或者依赖两个像素之间的运算顺序这些都很容易踩兼容性的坑。此外在Shader里少用discard因为它会打断GPU的Early-Z和像素着色器优化加剧描边处渲染压力。还有一点特别重要在使用任何新的图形API特性前一定要先做目标设备的小范围验证。不是所有手机都支持Stencil后处理或Compute Shader的随机写入。可以先用简单的测试场景在真机上跑一遍基础功能再铺开做参数优化。5.3 后续可以怎么扩展StrokeGen这套思路搭好之后扩展空间其实是很大的。比如可以在描边图的基础上接入“笔刷纹理”通过UV映射或世界坐标映射让线条自带手绘的纸纹、颗粒感也可以给描边加入颜色渐变让线条在靠近主光源时变暖、背光时变冷增强体积感和故事感还可以把它跟轮廓光、影绘等效果结合起来组成一整套日式卡渲最终表现方案。如果你单位里有专门的GPU算子开发角色甚至可以把方向场估算部分用Compute Shader替代Pixel Shader用更少的内存占用和更高的缓存友好度跑出更好的性能。在移动端追求极致性能时这通常是一步关键优化。我自己就在项目里把描边强度图额外输出给Bloom做深度掩码让发光效果只在轮廓线上出现。最终的效果是很“二次元”的那种发光描边观众一眼就能看出风格化但实现成本却很低。类似这样的扩展其实还有很多值得玩的空间关键在于知悉管线的边界在哪里。6. 最后再分享一点边界与心得如果你要开始做自己的GPU实时卡渲描边方案我建议你从最基础的版本入手只采样深度和法线输出一张Untextured的纯色描边图跑通一遍。不要一开始就上方向性模糊、Sobel、运动矢量那一套先把主循环跑通再逐步加料。这样每一步出问题都能快速定位。另一个非常想强调的心得是描边这个效果60%是参数调出来的30%是兼容性磨出来的只有10%是真正的算法创新。市场上很多看起来惊艳的卡渲效果底层用的都是很基础的技术但人家在阈值、流向、模糊半径等细节上抠得非常细。所以别小看“调参”这件事它是把技术转化成美术表现最直接的一环。我踩过很多坑。印象最深的一次为了消除一个角色耳朵边缘的描边闪烁前后折腾了快两天最后发现只是法线纹理压缩格式的问题——高质量法线贴图遇到ASTC压缩后边缘像素出现了肉眼可见的偏差导致后处理检测误判。所以说碰到某些“只在高画质下出现”的诡异描边问题时先怀疑纹理格式再怀疑Shader逻辑。这篇经验如果你能用上不妨从里面挑两三个点先做起来比如给描边加分辨率归一化比如把边缘强度图可视化成灰度图调试。这两个改动很简单但对项目手感和开发效率的提升立竿见影。等跑顺了再慢慢往StrokeGen的方向靠。