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):用来识别模型表面朝向的突变,比如棱角、接缝。
- 基础色/材质ID(BaseColor/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的方向靠。