ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用SDF控制卡通阴影边界:原理、生成与Shader采样

用SDF控制卡通阴影边界:原理、生成与Shader采样 卡通渲染做到一定阶段你一定会遇到一个绕不开的问题阴影边界不干净。SDFSigned Distance Field有符号距离场在图形学里最常见的身份是字体渲染和场景重建的利器但我在卡通渲染项目里用它做的却是另一件事生成和控制卡通阴影图。简单说SDF能让阴影边界从“物理计算的自然结果”变成“美术手绘的受控曲线”同时自然抗锯齿、动态光源下不跳变。这篇文章想把这套方法的原理、生成流程、Shader采样细节和踩坑经验一次讲清楚适合已经写过基本卡通Shader、但对阴影边界控制不够满意的读者。1. 为什么卡通阴影会用到SDF先理清要解决的问题1.1 卡通阴影的本质是给光照做了量化卡通渲染做了这么多年我越来越觉得它的核心不在“描边”也不在“高光”而在于阴影怎么“收”。传统PBR光照是一条连续曲线从亮到暗的过渡非常自然但卡通风格要的是一块一块的色阶暗部就是暗部亮部就是亮部中间要么有一条干净的分界线要么有一道宽度一致、刻意设计的柔和过渡带。实现这种“量化”光照最直接的办法是把dot(N, L)的结果塞进一个阶梯函数里。很多入门教程里都会写这样一段Shaderfloat ndl dot(normalize(N), normalize(L)); float shade step(0.0, ndl);这样模型表面就被一刀切成受光面和背光面。美术想要的“两段式阴影”“三段式阴影”可以通过叠加多个step或者改用一维渐变纹理ramp texture调出来。这就是卡通阴影图的原始形态把连续光照值做离散化映射。但这个朴素方案在实际项目中会遇到两个问题。第一step是硬阶跃像素在边界处只有0或1两个返回值只要模型法线稍微有点不平滑边界线上就会出现明显的锯齿和抖动。第二也是更致命的问题阴影边界是全局跟随法线和光照方向的完全不受美术控制。默认的NdotL形成的边界在脸颊、下巴、鼻翼这些地方往往非常“物理正确”却不符合动画人物的审美。比如角色脸颊上那条标志性的阴影弧线物理上它可能落在颧骨下方但美术想要的效果是在耳前就折过去。后来大家开始用ramp图把NdotL当作纹理坐标去查一张一维渐变图过渡带可以画得很柔和。但ramp图解决的只是“过渡长什么样”并没有解决“阴影边界落在哪”。当人物头部转动、灯光移动时边界还是跟着NdotL走鼻翼处的阴影依然会上翻形状依然不可控。1.2 NdotL阶梯化最大的痛点边缘噪声和过渡不可控刚入行的时候我用过一个很“天真”的做法把NdotL的计算精度提高模型面数也加密再叠加MSAA。结果边缘确实没那么锯齿了但法线稍微有点瑕疵边界就会出现一小截一小截的断层看起来像被虫咬过。后来我才意识到卡通阴影的边界问题本质上不是几何锯齿而是采样阶跃。模型的法线是逐顶点插值的插值出来的是一个连续变化的值但只要光照阈值在变化曲线的某个位置一切这个连续变化就被瞬间压成0和1两个值任何介于中间的法线都会被归到其中一边于是微小偏差会被无限放大。更麻烦的是过渡不可控。美术画了一张很漂亮的ramp图进入暗部之前有一段渐变。但实际渲染时发现这个渐变的宽度并不均匀受光面和背光面的过渡带在转折处会被拉宽在平坦处会被压缩。原因在于ramp图是按NdotL均匀采样的但NdotL在曲面上的变化率并不是均匀的。球面上靠近轮廓线的部分法线角度变化很快NdotL的斜率很大所以同样一段ramp渐变在轮廓附近会缩得很窄而在正面平坦区域NdotL变化很慢同一段渐变会被拉得很宽。美术想要的那种“上下一样宽的阴影过渡”用一维ramp是没法精确控制的。换句话说卡通阴影需要的不只是一个“光照强度到颜色的映射”还需要一个能控制边界位置、宽度、形状的参数场。这个参数场最好还能被美术直接编辑而不是在Shader里调一堆魔法数字。1.3 SDF在这里不是建模工具而是阴影边界控制器SDF大家最熟悉的应用是做字体渲染和场景隐式建模。它的核心是给空间中的任意一个点返回它到某个形状边界的最短距离负号表示在形状内部正号表示在外部。但在卡通阴影的应用里我们不是拿它去表达几何体而是拿它去表达“阴影边界应该偏移多少”“过渡带应该多宽”。具体来说可以把一条手工定义的目标阴影边界曲线比如人物脸颊上一条漂亮的弧线转换成一张SDF图图里的每个像素记录到这条弧线的距离。绘制阴影时我们从SDF图里取出当前像素对应位置到边界曲线的距离把这个距离作为NdotL阈值的一个修正量或过渡宽度。这样一来阴影边界就不再是纯粹由法线和灯光的点积决定的物理结果而是变成了有效光照 NdotL SDF采样值修正 阴影 阶梯化(有效光照)SDF在这里承担的是一个查表角色把美术手绘的边界形状“烘焙”成一张可采样的纹理然后在渲染时用它来修正光照结果。这也是现在很多日式卡通渲染项目里所谓“SDF卡通阴影图”的常见做法。我第一次看到这种思路的时候觉得很反直觉因为SDF听起来是一个非常“几何”的东西结果它被用在了非常“美术”的环节。但它确实有效美术画一条线程序把线转成距离场渲染时用距离场修正阴影边界边界就完全受控了。下面我会把从生成到采样的完整链路展开讲。2. 从原理到纹理一张SDF卡通阴影图是怎么生成的2.1 先定坐标把光照方向映射到纹理空间做SDF图之前首先要明确这张图的“坐标”是什么。一维ramp图只有一个轴采样坐标是NdotL已经不能满足需求。二维SDF图最常用的参数化是横轴NdotL范围从-1到1纵轴NdotV或者模型某个局部坐标的映射。为什么需要第二个轴因为同一个NdotL值在球体正面和球体轮廓处法线变化速度差很多。如果只有横轴等于默认所有表面在光照坐标下的变化率都一样这显然不准确。加入NdotV或者其他参数后SDF图可以表达“在不同视角、不同朝向区域阴影边界的阈值或过渡宽度不同”。把NdotL和NdotV映射到纹理坐标时要注意保留负值区域。NdotL为负意味着表面完全指向光源背面这块地方通常是传统step函数里的全阴影区。但在SDF修正方案里负值区域同样需要采样距离场因为我们要给阴影边界预留“向外扩张”的空间——美术可能希望阴影边界比物理位置更靠前或者更靠后。映射公式很简单float u clamp(0.5 0.5 * ndl, 0.0, 1.0); float v clamp(0.5 0.5 * ndv, 0.0, 1.0);注意这个映射只适合做常规采样。如果你需要阴影边界超出NdotL 0的物理位置要么在生成SDF图时就预留好扩张区域要么在Shader里允许采样超出[0,1]的UV。后一种情况我一般配合TextureWrapMode.ClampToEdge之外的透贴模式处理但用起来要格外小心因为clamp会在边界处产生错误的距离值。2.2 解析SDF的核心公式点到边界的最短距离SDF图生成阶段的核心任务是把一条或多条目标阴影边界曲线离散成符号距离场。最基础的距离对象是直线和二次曲线。先说直线。假设边界曲线在纹理空间里由两个点定义那么任意像素点p到这条线段的距离可以拆成三个区域计算点在线段的正上方/正下方时距离是到线段的垂直距离点在端点两侧时距离是到最近端点的距离。用GLSL实现一个线段SDF没多长float sdSegment(vec2 p, vec2 a, vec2 b) { vec2 pa p - a; vec2 ba b - a; float h clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); return length(pa - ba * h); }这个函数返回的是无符号距离。如果还要区分边界的内外就需要确定法线方向。卡通阴影里通常约定边界曲线一侧是“阴影区”另一侧是“非阴影区”。从曲线指向阴影区内部的方向作为距离正方向那么阴影区内部的像素距离为正非阴影区为负。这个符号决定了最终阴影边界偏移的方向非常重要。对于更复杂的边界比如面部阴影那条带弧度的线一般不会手写解析SDF而是用美术绘制好的黑白蒙版做距离变换Distance Transform。做法是把美术画的边界线渲染成一张二值图然后从每个非边界像素出发找最近边界像素用BFS或Jump Flooding算法计算欧氏距离。这个过程是离线的CPU或者GPU都行不需要实时算。2.3 把距离值编码进纹理8位还是16位得到距离值之后下一步是编码进纹理。这里有个很实际的坑距离值可能是负的也可能是正的范围取决于纹理分辨率和对边界扩张的需求。直接存在8位纹理里如果不做归一化精度会惨不忍睹。我的建议是先用一个有符号距离范围比如[-maxDist, maxDist]然后归一化到[0,1]存储float normalized dist / maxDist * 0.5 0.5;8位纹理理论上够用但只在过渡带比较窄、分辨率足够高时表现好。一旦边界附近抗锯齿宽度比较小8位的量化误差会直接体现在渲染结果上边界会出现轻微的色带。因此我通常用R16G16的单通道16位浮点纹理或者至少用R8但把过渡带铺到尽量大的距离范围。更高端的做法是R8纹理配合SDF的重映射。因为我们真正关心的不是距离本身的绝对值而是它经过阈值化和smoothstep之后的结果。只要边缘附近距离的导数足够平滑8位也能渲染出干净边界。但这就对生成算法的连续性要求更高了BFS距离变换天然是离散的想要连续的导数一般需要再做一个高斯平滑或者中值滤波。2.4 CPU端生成流程与参考伪代码这里给一个完整的CPU端生成流程可以照着写工具准备一张目标区域的UV展开图美术手绘边界蒙版或者程序化生成曲线把曲线栅格化成二值图边界像素标记为0其它像素标记为一个大数做两轮双扫描或者BFS得到每个像素到最近边界像素的欧氏距离确定边界法线方向从非阴影侧指向阴影侧赋予符号归一化并写入纹理图集。核心伪代码距离变换部分曼哈顿距离示意// 第一遍从上到下从左到右 for (int y 1; y h; y) { for (int x 1; x w; x) { grid[y][x] min(grid[y][x], min(grid[y - 1][x] 1, grid[y][x - 1] 1)); } } // 第二遍从下到上从右到左 for (int y h - 2; y 0; y--) { for (int x w - 2; x 0; x--) { grid[y][x] min(grid[y][x], min(grid[y 1][x] 1, grid[y][x 1] 1)); } }这段代码是曼哈顿距离近似。实际项目中如果要精确的欧氏距离需要维护最近边界点的坐标在传播时比较欧氏距离而不是网格步数。更省事的方案是用Jump Flooding复杂度更低而且对复杂形状的误差更小。我这里给的是基础版本方便第一次接触的人理解。3. 采样端的正确姿势Shader里怎么用这张图3.1 核心代码根据光照方向和法线查SDFSDF图和普通ramp图在Shader端的用法非常像区别就在于采样回来的值不是直接的颜色而是“到阴影边界的距离”。下面是一段可跑的GLSL片段uniform sampler2D u_SDFTex; uniform float u_ShadowThreshold; uniform float u_ShadowSoftness; vec3 shadeSDF(vec3 N, vec3 L, vec3 V, vec2 uv) { float ndl dot(N, L); float ndv dot(N, V); vec2 sdfUV vec2(clamp(ndl * 0.5 0.5, 0.0, 1.0), clamp(ndv * 0.5 0.5, 0.0, 1.0)); float dist texture(u_SDFTex, sdfUV).r; dist dist * 2.0 - 1.0; float shadow 1.0 - smoothstep( u_ShadowThreshold - u_ShadowSoftness, u_ShadowThreshold u_ShadowSoftness, ndl dist ); return mix(u_ShadowColor, u_LitColor, shadow); }把dist加到ndl上等于让SDF图去偏移阴影判定点。如果某个像素的SDF距离是正数它就需要更大的ndl才能进入亮部也就是更容易变成阴影反之则更难变成阴影。这样美术就可以通过修改边界曲线的位置来精确控制暗部范围。这里有一个容易忽略的点u_ShadowSoftness的单位。它和ndl是同一个量纲但它和dist也是同一个量纲。因为最终比较的是ndl dist和u_ShadowThreshold所以smoothstep的宽度可以直接用距离单位。可是实践中很多人会把softness直接设成0.02、0.05这种固定值结果在不同距离、不同模型密度下表现不一致。下面会讲如何解决。3.2 抗锯齿与smoothstep为什么smoothstep要和fwidth挂钩很多人看到smoothstep就直接照抄但传参很有意思。你写的边缘过渡宽度要跟SDF的距离单位匹配。如果SDF图的纹理像素密度不同于模型表面的密度直接给固定smoothstep宽度会出现看起来不一样的虚边模型近处的时候边缘硬得像刀远处的时候边缘糊成一片。更稳的办法是让过渡宽度跟SDF距离梯度挂钩。常用的做法是同时计算SDF距离值在屏幕空间的导数用fwidth(dist)获取当前像素在屏幕空间的距离变化率再把过渡宽度设成它的某个倍数float delta max(u_ShadowSoftness, fwidth(dist)); float shadow 1.0 - smoothstep( u_ShadowThreshold - delta, u_ShadowThreshold delta, ndl dist );这样做的好处是不管模型在多远处阴影边缘在屏幕空间的抗锯齿宽度都大致稳定。但这里有个前提dist本身必须是连续变化的。如果SDF图的分辨率太低dist的变化本来就是跳跃的fwidth会变得很大导致过渡带被拉得非常宽甚至整个模型变成中间调。所以fwidth只是一种保护机制不能用来掩盖SDF分辨率不足的问题。3.3 mipmap、双线性过滤和压缩格式的坑SDF纹理和普通颜色纹理在过滤上有一个本质区别你希望它保存的是“距离”这个数值而不是“颜色”。默认的双线性过滤对距离值的插值是好的它符合距离场线性插值的需求。但mipmap是个大坑。普通的mipmap生成是对上一级做平均这个操作会毁掉SDF中的符号信息一个正距离和一个负距离平均之后可能变成接近0的值导致边界偏移量被错误地抹平。有两个解决办法。第一个是关闭mipmap采样时只用最近一层靠Shader里的fwidth自己做抗锯齿。第二个是使用专门为距离场设计的mipmap生成算法比如对每个mip级别做一次重新距离变换或者至少用max/min池化而不是平均池化。但大部分引擎里做这种自定义mipmap生成比较麻烦所以我在项目里更倾向于直接关mipmap反正SDF图分辨率通常不大性能压力可以接受。压缩格式也要小心。ASTC、ETC2这些有损压缩会对SDF数值引入比较大的误差尤其是过渡带边缘压缩后可能出现肉眼可见的块状瑕疵。如果最终包体尺寸没到极限我建议SDF图用未压缩或者R16的格式。如果必须压缩也要用RGBA8但把距离值的高两位做编码保护比如把距离值拆成两个通道存储降低单通道的量化误差。3.4 移动端和PC端的差异移动端GPU对纹理过滤和mipmap的行为和PC略有差异尤其是iOS和Android的高端机。SDF采样在移动端最常见的现象是边界颜色跳动尤其在低精度FP16的片段着色器上。原因是dist * 2.0 - 1.0这个解码操作在FP16下会把很小的距离值放大成明显的噪声。解决方法是把解码放到采样之前或者生成纹理时就把有效距离范围做大避免接近0的距离被精度吃掉。PC端则可以更放肆一些直接上16位浮点纹理配合textureGather做4点采样在Shader里手动插值得到比硬件双线性更可控的结果。不过这种优化属于细枝末节除非你的项目对阴影边缘质量要求极高否则默认双线性就够了。我倾向于先跑通主逻辑再根据真机效果决定要不要做这些微调。4. 进阶玩法动态光源、自阴影和轮廓光4.1 光源旋转问题二维SDF图的旋转对称处理前面写的采样代码假设SDF图只和NdotL、NdotV相关这在单一固定光源下没问题。但一旦光源开始旋转阴影边界应该跟着旋转而SDF纹理本身并不会自动转。一个常见的错误是直接把光源方向丢进Shader然后用世界空间的N和L计算ndl。但美术画的SDF边界是基于模型局部空间的。比如角色的左脸颊有一条固定的阴影弧线灯光转到右边时那条弧线也应该跟着转。要处理这个问题通常要把L变换到模型的切线空间或局部空间再用局部L计算ndl。更省事的做法是在角色渲染时把光源方向转换到每个材质依赖的“阴影朝向空间”。这个空间可以是一张贴图提供的一系列朝向向量把L投影到每个像素的切线帧上。SDF图里的横轴不再直接是ndl而是dot(N_local, L_local)其中N_local来自模型UV展开时固定的切线法线。这样灯光旋转时SDF图的采样UV会跟着变边界位置自然也就跟着灯光转过去了。4.2 自阴影和AO怎么往SDF结果上叠卡通渲染很少只用一盏主光。角色身上还需要自阴影和AO来增加体积感但这些效果和SDF阴影图叠加时很容易打架。我的做法是分层处理SDF主阴影负责“造型”决定明暗两块的边界AO贴图负责“接触阴影”在领口、裙摆、腋下这些位置压暗自阴影如果开了阴影贴图负责“几何遮挡”只影响暗部内部不影响边界。叠加时最好按乘法但要注意不要让AO把SDF阴影边界变糊。推荐的做法是先算SDF阴影得到明暗系数再用AO在暗部内部做二次压暗float base mix(u_ShadowColor, u_LitColor, shadow); float ao texture(u_AOTex, uv).r; float finalAO mix(1.0, ao, shadow); color.rgb base.rgb * finalAO;这样暗部里可以保留更丰富的层次但明部和暗部的分界线还是由SDF图决定AO不会去破坏已经调好的造型边缘。如果你想让AO也参与边界造型可以把shadow替换成经过smoothstep但宽度更大的暗部系数但这种情况比较少见。4.3 同一个SDF图做描边和轮廓光的思路我后来发现既然已经有了到阴影边界的距离场那它其实也可以用来生成轮廓光。原理很简单把SDF图里的距离取绝对值得到“离边界有多远”。靠近边界的地方用一个带状窗口选出叠加边缘光颜色。做法float edgeMask 1.0 - smoothstep(0.0, u_RimWidth, abs(dist)); color.rgb u_RimColor * edgeMask * ndvFalloff;当然这里的dist是SDF图里的距离不是几何意义上真正的轮廓距离所以得到的边缘光形状会更“风格化”会自动跟着美术设计的阴影边界走。想要变成描边就把abs距离的窗口调到很窄再叠加到暗部侧。这样一张SDF图在同一个角色材质里既管阴影边界又管轮廓亮度挺划算的。不过要注意这种边缘光本质上不是基于视角的它不会像标准菲涅尔那样在轮廓处自动亮起而是完全由SDF边界位置决定。所以更适合用来做“风格化的轮廓光”或“漫画式的强调边缘”不适合做真实的rim light。想要两种效果共存就把标准菲涅尔和SDF边缘Mask做插值。5. 实测对比与参数调优5.1 分辨率、过滤方式和边缘过渡宽度的组合测试我做过一组简单测试SDF图分辨率分别用128、256、512过渡宽度分别取0.02、0.05、0.1。结论不算意外分辨率越高距离值的最大误差越小但真正影响画面观感的是过渡宽度和fwidth的匹配关系。128分辨率的SDF图如果把过渡宽度开到0.05和256分辨率开0.02的效果很接近但128更容易出现边界抖动因为在距离梯度大的区域相邻两个像素的距离值跳跃很大。如果模型面数高、UV密度均匀256分辨率已经非常够用。512主要留给角色脸部这种需要精细控制阴影弧线的区域。我最终的角色材质里身体用256脸用512描边单独用128效果和性能都比较均衡。5.2 性能实测一张纹理采样和解析距离计算的差距有人可能会问既然距离函数可以直接在Shader里算为什么还要提前烘焙成纹理我对比过两种方式。实时计算一个线段的SDF只有几条指令理论上更快。但问题在于美术不可能用线段表达式去手调阴影边界。纹理方案把美术的编辑成本降下来而且采样次数一般只有1次对移动端GPU的压力可以忽略。实测在一台骁龙8Gen2的设备上整个SDF阴影部分新增的指令大约比原来基于step的卡通阴影多出10%左右主要是多了一次纹理采样和smoothstep相关的ALU操作。相对的如果用实时解析SDF去模拟同一条弧线需要多次距离判断ALU和分支开销反而更高而且边界无法被美术直观控制。所以从工程角度看烘焙SDF图是更划算的方案。5.3 我最终推荐的工作流最后给一套我验证过的默认参数适合大多数二次元风格角色参数项推荐取值说明SDF图分辨率256脸部512过小会抖过大收益低通道格式R16G16或RGBA8特殊编码避免有损压缩破坏距离值过滤方式双线性关闭mipmap防止mipmap抹掉符号信息过渡宽度max(0.04, fwidth(dist) * 1.5)兼顾近处硬边和远处抗锯齿采样UV映射横轴ndl纵轴ndv保留视角变化对边界的影响阈值中心0.0正负范围[-0.5, 0.5]够用这套参数在PC和移动端都能稳定跑阴影边界干净灯光动态变化时也不会出现跳变。如果要让阴影边界更“糊”就把过渡宽度调大但不要超过0.1否则会失去卡通阴影那种“刻意”感。6. 遇到问题怎么排查踩坑清单6.1 阴影边缘出现波浪纹/抖动这类问题绝大多数出在SDF距离值和屏幕空间的导数不匹配上。检查顺序是先关mipmap再看fwidth是否过大最后检查SDF图的编码是不是有负值被吞掉。我遇到过一次很诡异的情况阴影边缘在模型旋转时出现明显“呼吸感”后来发现是纹理压缩后负距离值被钳制到0导致整个阴影判定整体偏移。换成未压缩的R16纹理后立刻恢复正常。排查这类问题最快的方式是把SDF采样值直接暴露到材质调试面板上滑块拖动时观察边界变化是否连续。6.2 动态旋转光源时阴影跳变光源旋转导致边界跳变一般是UV映射用的法线空间不一致。场景里的L是世界空间而SDF图采样轴需要的是模型局部空间或切线空间。解决办法是写一个简单的调试模式把SDF采样UV可视化光源旋转时观察UV移动是否平滑。如果UV在某个角度发生瞬移就是使用了错误的变换矩阵。另一个容易忽视的点是角色蒙皮。布料的UV是固定的但法线会随骨骼动画变化。如果你的ndl用了插值后的法线在关节位置边界会抖动。可以考虑用顶点色或者stiffness贴图把关节区域的阴影边界锁定或者直接使用顶点色混合SDF距离值让关节附近的阴影偏移尽量不被法线插值干扰。6.3 打包和压缩后SDF纹理脏掉这是最常见的美术侧问题。美术在编辑器里看SDF图边界非常干净打包后却出现脏边。原因几乎都是压缩格式和mipmap。移动端用ETC2/ASTC压缩后SDF图的低距离区域误差会被放大。建议打包前检查压缩后纹理的实际像素值和原始值之间的误差分布误差超过0.02就考虑换格式。还有一次是资源导入时默认勾选了sRGB导致SDF值在Shader里被错误地做了伽马解码整体偏移。SDF图作为数据纹理一定要关闭sRGB标签。这个坑特别隐蔽但排查起来也最快把采样值直接输出成颜色如果亮部整体变暗、暗部整体变亮基本就是sRGB没关。6.4 负距离被UV Clamp截断最后说一个容易踩的细节。当你允许SDF修正超出ndl 0的物理阴影位置时采样UV可能会跑到[-0.1, 1.1]这样的范围。默认的ClampToEdge会把负距离硬送到边界像素导致阴影边界卡在一个固定位置不动。我一般把SDF图UV clamp到[-1, 1]或者使用纹理border颜色让超出的像素得到一个恒定的、足够大的距离值保证阴影不会乱跳。就我个人的项目经验来说SDF卡通阴影图真正难的不是距离算法而是把它和现有的渲染管线缝在一起时不破坏美术的直觉。一旦把“距离值”这个中间量在工具链里打通让美术能直接画线改阴影边界这套方案就比传统ramp图实用太多了。如果你也在卡卡通阴影的边界控制建议直接拿一张现有的角色贴图先生成一张128分辨率的SDF图跑通这套流程再慢慢优化比从头看论文快得多。
返回列表