日系扁平风格插画和边缘光这两个词放在一起在过去很多年都是“水火不容”的。做日系的人觉得边缘光是厚涂和赛博朋克的专利往平涂里一放整个画面立刻变得油腻、廉价那股清爽干净的劲儿全没了。我有段时间也这么以为直到有一次项目需要在不破坏扁平质感的前提下给角色加一圈动态变化的高光试了一圈现成方案全都翻车才痛下决心把整套渲染管线自己撸了一遍。这篇文章不聊理论框架直接把我最终落地的这套“动态边缘光管线”完整拆给你看为什么日系扁平里加边缘光十有八九会丑、我踩过的几个典型方案雷区、最终管线每个环节的具体设计以及那些参数到底怎么调才不破坏扁平感。东西不复杂但每一个坑都是真金白银换来的。1. 日系扁平为什么容不下边缘光先撕掉“发光好看”的刻板印象很多人在日系扁平里做边缘光翻车不是技术不行而是压根没想明白一件事日系扁平的美学核心是“反光照”的。1.1 扁平画面的底层视觉逻辑日系平涂风格无论是赛璐璐上色、还是现在流行的多色渐变平涂它的核心特征是弱化甚至完全去掉物体表面的连续灰度过渡。阴影是一块独立的色面高光是明确的几何形状不会像厚涂那样按物理光照衰减来一点点晕开。这种画面里观众默认的视觉语言是光是被“归纳”过的而不是被“计算”出来的。我们看新海诚那类背景为什么敢用大面积逆光和强边缘光因为背景不是扁平角色它承担的是气氛渲染任务。但角色一旦上了赛璐璐层全世界的日系动画都在遵守一个潜规则角色身上的光是平的、块状的、符号化的。观众已经被这个语言训练了很多年突然在角色轮廓上看到一圈沿着物理轮廓精确衰减、还带着动态波动的光照大脑第一反应不是“好高级”而是“这玩意儿是叠上去的吧”。1.2 为什么直接套屏幕空间边缘光必然翻车屏幕空间边缘光Screen Space Rim Light是很多现成插件的基础方案做法是在后处理阶段通过深度法线贴图采样生成轮廓高亮。这个方案放在写实或PBR管线里好用因为它的物理衰减本身就是写实画面的一部分。但搬到日系平涂上当场就是一场灾难精度不对。屏幕空间的边缘提取是像素级的它会忠实把发丝、衣褶、眼睛轮廓全都勾一遍。包平涂角色的线条本身就粗再叠一层像素级光圈整个画面像没对上焦糊成一团。衰减不对。物理正确的衰减是exp式的靠近轮廓线骤亮然后快速衰减。但平涂画面的色块是硬边的这种软衰减高光落在硬边色块上边缘会浮现一圈虚虚的光晕和线条直接打架。动态不对。屏幕空间方案依赖深度缓冲画面一抖动光边就在轮廓上来回跳动动起来像信号不良的老电视。日系动画的运行镜头本来就喜欢用平移和缩放这种抖动感格外显眼。我第一次拿现成插件试的时候效果出来直接被美术同事评价为“像贴了劣质荧光胶带”。问题的根源不是光的颜色或强度而是光的语言和画面语言压根不兼容。扁平画面要用边缘光必须先把这圈光变成和色块一样的“扁平符号”而不是物理光照模拟。1.3 破局思路把边缘光做成“会动的色块”想通了上面那个道理整个设计方向就变了。我不再试图去模拟“轮廓受光”这一物理现象而是把边缘光当作一块独立的颜色面——它和角色的赛璐璐阴影块是同一层级的元素只是它沿着轮廓运动、流动、变化。这样做的效果是光不再像贴上去的滤镜而是本来就长在画面上的一层动态元素。这个思路一确立后面对技术的每一个取舍都有了判断标准。任何让光的边缘变软、渐变、像素级精确的方案统统淘汰。任何让光保持硬边、色块感、并且可以整体流动的方案优先选用。这也直接决定了我最终搭建管线时选择的每一层技术。2. 动态边缘光的三块技术地基几何检测、扰动动画、混合排序在动手搭管线之前先把我用到的三项核心技术说清楚你可以把它们理解为管线的三个地基。每一个单独拿出来都不复杂但组合方式决定了最终效果的上下限。2.1 几何级的轮廓光检测法线重构与视角点积要拿到“沿着轮廓走”的光边第一步是精确知道轮廓在哪里。在非屏幕空间方案里通用做法是获取屏幕像素对应的世界法线和视线方向通过点积计算“该像素朝向观察者的程度”。公式很简单float3 normalVS normalize(mul(normalWS, (float3x3)UNITY_MATRIX_V)); float perpective 1.0 - saturate(dot(normalVS, float3(0, 0, 1)));这里的关键是法线必须在视空间下计算。世界空间法线直接拿来做边缘检测会出现很经典的错误正面朝向摄像机的平面永远检测不到边缘因为它的法线和视线方向平行。而视空间法线等于把所有法线转换到以摄像机为原点的坐标系里再用点积算出每个像素“有多正对着镜头”。得到“边缘概率”之后用smoothstep把它的响应曲线压成硬边。这个smoothstep的幅度就是前面说的——画面语言的关键。值越小光边越硬越像色块值越大光边越虚越像物理光照。在做日系扁平的时候我把smoothstep的过渡范围压得非常窄通常只有0.05到0.1这样光边几乎是一条明确的线而不是一片朦胧的过渡带。2.2 让光“活”起来动态扰动和时间流静态边缘光顶多是给角色加了一条亮边但“动态”才是这套管线的灵魂。我这里用了两层动态第一层是低频扰动。给光边的强度乘上一个噪声项噪声用时间变量驱动让光边在轮廓上的明暗出现缓慢的、整体的波动。这模拟的是很微妙的光源晃动感像站在水边反光随着水面起伏忽明忽暗。这一层我用的噪声是简单的二维分形噪声采样的UV和角色的屏幕空间UV对齐这样可以保证扰动在画面上是平滑连续的不会出现一个局部突然闪断。第二层是高频流动。做一个沿轮廓方向流动的波浪效果让光边看起来像沿着角色轮廓在“行走”。实现方式是把视角点积结果加一个时间偏移float flowOffset _FlowSpeed * _Time.y; float linearEdge perspectiveValue flowOffset;这样写的好处是流动的方向和速度都容易控制。流动让光不再是静止的轮廓描边而像是有一道能量在沿着身体轮廓巡回。镜头稍微转一转光的位置变化和镜头位移叠加起来动态感会非常强。这里需要注意一个坑扰动强度必须和屏幕分辨率解耦。直接在片元着色器里做基于屏幕空间UV的噪声采样时分辨率越高同一段屏幕上采样的噪声频率越高动态效果反而越密越碎。我最后是把噪声采样放在低分辨率的RT上做再用双线性过滤放大到全屏这样不管画面是1080p还是4K扰动频率保持稳定。2.3 光与画的融合顺序先平面后叠加第三块地基也是很多人在自己做方案时最容易搞砸的——边缘光到底怎么和原本的扁平画面融合。最常见的错误是直接在最终输出图像上做additive混合。这样出来的光会“发白”把原本赛璐璐色块的色彩纯度洗掉看起来像是画面上面蒙了一层纱。我采用的顺序是正常渲染扁平画面输出一张不包含光边的彩色图。单独输出一张只含边缘光强度的灰度图或者带有自定义颜色。在合成阶段先根据边缘光强度图给这张灰度图加一点高光色相再做一次色彩空间对齐然后用multiply混合到原画面上最后根据亮度阈值做一次clip。这样做的结果是光边不管怎么亮它不会把底下色块的饱和度洗白而是在色块上面覆盖一层“发光”的颜色像一层真实的涂装。简单说就是让光可以鲜亮但不允许它“变灰”。混合顺序这一点如果我一开始就明白至少能省掉两周的返工时间。3. 正式搭建管线一个适合平面插画场景的动态边缘光实现理论基础再漂亮最终还是要落在代码和参数上。下面我把我最终采用的方案完整写出来。强提醒这套方案不是唯一的正确答案但它是围绕“扁平语言”这个目标逐项调试后稳定工作的方案尤其适合需要导出透明序列帧的2D动画或Live2D项目。3.1 环境与前置准备我的调试环境如下Unity 2021.3 LTSURP管线Shader模型CG/HLSLURP的Shader在大部分现代平台上都能跑后处理框架直接挂在URP的RenderFeature上不走第三方插件美术资源角色为透明背景的序列帧PBR通道为空无法线贴图色彩以赛璐璐色块为主如果你用的是内置渲染管线Built-in本质原理完全一致只需要把HLSL的语法换成对应的CG写法并把获取法线的部分改成从自定义摄像机深度法线纹理采样即可。为了演示简洁我直接讲URP实现。3.2 完整Shader代码与逐段解释先给出一版可直接运行的核心Shader代码它包含了边缘光的强度计算、流动扰动、颜色合成三个主要模块Shader FlatRim/FlatDynamicRimLight { Properties { _MainTex (CharacterTex, 2D) white {} _RimColor (Rim Color, Color) (1, 0.8, 0.4, 1) _RimThreshold (Rim Threshold, Range(0.0, 1.0)) 0.4 _RimSmooth (Rim Smooth, Range(0.01, 0.5)) 0.05 _FlowSpeed (Flow Speed, Range(-2.0, 2.0)) 0.3 _NoiseScale (Noise Scale, Range(0.1, 10.0)) 1.5 _NoiseStrength (Noise Strength, Range(0.0, 1.0)) 0.2 } SubShader { Tags { RenderTypeTransparent QueueTransparent } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Off Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_instancing #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; float3 normalOS : NORMAL; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; float3 normalWS : TEXCOORD1; float3 positionWS : TEXCOORD2; }; CBUFFER_START(UnityPerMaterial) float4 _MainTex_ST; float4 _RimColor; float _RimThreshold; float _RimSmooth; float _FlowSpeed; float _NoiseScale; float _NoiseStrength; CBUFFER_END TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); TEXTURE2D(_CameraDepthTexture); SAMPLER(sampler_CameraDepthTexture); float hash(float2 p) { return frac(sin(dot(p, float2(12.9898, 78.233))) * 43758.5453); } float noise(float2 p) { float2 i floor(p); float2 f frac(p); float2 u f * f * (3.0 - 2.0 * f); return lerp( lerp(hash(i), hash(i float2(1, 0)), u.x), lerp(hash(i float2(0, 1)), hash(i float2(1, 1)), u.x), u.y ); } Varyings vert(Attributes IN) { Varyings OUT; VertexPositionInputs positionInputs GetVertexPositionInputs(IN.positionOS.xyz); VertexNormalInputs normalInputs GetVertexNormalInputs(IN.normalOS); OUT.positionHCS positionInputs.positionCS; OUT.uv TRANSFORM_TEX(IN.uv, _MainTex); OUT.normalWS normalInputs.normalWS; OUT.positionWS positionInputs.positionWS; return OUT; } half4 frag(Varyings IN) : SV_Target { float4 baseColor SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv); // 1. 视空间法线计算 float3 normalVS normalize(mul(IN.normalWS, (float3x3)UNITY_MATRIX_V)); float perspectiveFactor 1.0 - saturate(dot(normalVS, float3(0, 0, 1))); // 2. 动态流动偏移 float flowOffset _FlowSpeed * _Time.y; float flowFactor perspectiveFactor flowOffset; // 3. 噪声扰动 float n noise(IN.uv * _NoiseScale _Time.y * 0.1); flowFactor (n - 0.5) * _NoiseStrength; // 4. 硬边阈值检测 float rimMask smoothstep(_RimThreshold, _RimThreshold _RimSmooth, flowFactor); // 5. 合成输出保留扁平色块的透明度 half3 finalColor baseColor.rgb _RimColor.rgb * rimMask * _RimColor.a; half finalAlpha baseColor.a; return half4(finalColor, finalAlpha); } ENDHLSL } } }如果直接拿这一段代码去用你可能已经能跑出一个还不错的静态光边了。但这个代码本质上还是“在画面上叠了一圈颜色”离我前面说的“会动的色块”还有距离。接下来你要做的是把这套代码拆成三四个Pass或者接入你的后处理栈让光边和角色动态解耦。这里我强烈建议如果你要长期用别把逻辑塞在一个Pass里后面改起来很痛苦。3.3 流水线配置从单Pass到完整管线我的做法是把它做成一条三Pass管线Pass A预处理把角色的透明序列帧输入进来利用基础Shader先输出彩色图和一张只有Albedo、没有光照信息的“纯赛璐璐层”。这张图后面做合成用。Pass B边缘光生成拿Pass A输出的纯色块图加上角色的深度法线走一遍类似于上面的Shader单独输出一张只有光边强度信息的灰度图。这张RT的分辨率可以是全屏的1/4性能压力小很多后期也可以用双线性过滤放大。Pass C合成在URP的RenderFeature里做最后的混合。把彩色图和光边灰度图合在一起用前面说的“先multiply再clip”的方式限制光边只出现在角色的轮廓附近并且让光边的颜色单独可调。这样拆成三Pass的好处是显而易见的角色动画更新时Pass B不用每次都重新跑一遍完整的Shader只需要重新生成彩色图光边强度图可以复用省掉一大块计算量。调光边风格时不需要重新渲染角色直接改Pass B和Pass C的参数就行美术同事自己就能在Inspector上拖参数实时看效果。后期要输出序列帧时可以直接导出Pass A的纯色块图Pass B的光边图方便在AE或Nuke里二次合成这种灵活性对于需要走后期流程的内容创作是很大的优势。URP的RenderFeature里注册这套Pass时需要注意一件事如果角色是透明物体渲染队列得提前否则深度信息全被背景吃掉法线重构会直接失效。建议把角色放到Transparent队列的最前边并对摄像机开DepthTexture。4. 踩坑记录与参数调试调了三个星期才稳定的那些细节这一节写的全是血泪经验。表面看起来都是几个Parameter的调整但每一个背后都对应一个具体的翻车现场。如果你是直接拿我的步骤去复现大概率会栽在下面这几个地方。4.1 半透明边缘光的“透明度炸裂”问题做透明序列帧角色时最大的坑是边缘光的Alpha和角色Alpha互相干扰。很多方案是把边缘光直接加到角色RGB上但角色自身的Alpha通道是美术画的半透明赛璐璐一旦光边加进去Alpha边缘会出现一圈灰白的过渡。我最终的处理方式是在Pass C合成时对光边单独做一次Alpha分离。光边的Alpha只取决于法线点积值和角色原Alpha无关。这样即使角色某个局部是半透明的光边照样能硬硬地压在轮廓上不会产生半透明叠加的灰边。如果你的美术素材里角色有大量飘发、纱裙这类半透明结构这个改动几乎是必须的。具体做法是float rimAlpha rimMask * _RimColor.a; float combinedAlpha max(rimAlpha, baseColor.a);用max而不是加法可以保证光边永远压得住底部色块且不会把Alpha顶到过曝。实测这套逻辑在纱裙边缘、发丝末梢的表现非常干净。4.2 动态扰动“糊成一团”的罪魁祸首UV空间前面提到噪声采样要基于屏幕空间但屏幕空间UV在角色旋转时会跟着角色一起动看起来光边像贴在角色身后的一层布完全没有真实光感。后来我把噪声采样空间改成了世界空间采样的UV用角色的世界坐标的XY然后再做一层Tiling和Offset。改动之后光边扰动的频率不再跟着镜头抖而是像环境中真的有一层光线在流动。这也是为什么我建议把Pass A、B、C拆开——如果你只有一个Pass每次都要重新采样世界坐标Shader会笨重很多。拆开之后Pass B可以预先把扰动噪声做成一张动画贴图放到一个单独的寄存器里后面所有帧都能快速引用。4.3 性能预算与移动端适配这套管线如果放在PC上完全没问题但如果你要做移动端有几个地方必须提前做优化法线重构是相对重的一段计算在移动端建议直接把角色的法线烘焙到顶点色里在Pass B里直接读取顶点色省掉变换。噪声采样建议使用低分辨率RT比如256x256或512x512视觉差异很小但性能差距明显。我跑过测试全分辨率噪声在1080p下会额外吃3ms低分辨率版本几乎可以忽略不计。合成Pass的multiply和clip在部分移动端GPU上会触发half分辨率着色问题建议在合成时显式使用float精度或者干脆把光边的值范围限制在0~1里避免浮点精度穿透。4.4 参数速查表我把自己调试后觉得还比较稳的参数放在这里供参考参数推荐值说明_RimThreshold0.3~0.5低于0.3光边太宽高于0.5光边太细_RimSmooth0.03~0.08硬边做日系的关键别超过0.15_FlowSpeed0.1~0.4太快像故障效果太慢看不出动态_NoiseScale1.2~2.0越高扰动越碎越低越整体_NoiseStrength0.1~0.25超过0.3容易闪眼克制一点_RimColor.a0.6~0.9控制光边整体强度别拉满这些参数不是万能公式但足够让你从一个能看的状态起步然后再按自己画面风格细调。5. 从“会动的边”到“会呼吸的画面”扁平动态光的下一步管线搭完参数也稳定了按理说到这里就可以收工了。但我实际用了两三个月之后发现动态边缘光真正值钱的地方不是那圈光本身而是它把整套扁平画面的“动感语言”打开了。这里我分享几个深水区玩法算是这套管线给我的额外惊喜也会让你的作品远远甩开那些“只会做静态色块”的同行。5.1 边缘光作为“信息层”动态分区表现单纯的轮廓光只是一个全局描边但如果把它拆成多个分区比如头发区、身体区、衣物区并给每个分区单独控制光边的开关和流动方向就能很轻松地做出类似动画里的“演技”效果——角色害羞时只有脸颊边缘微微泛起光角色愤怒时头发边缘光速流动。这些都是用同一套管线只是在Pass B里对不同的材质通道设置不同的参数。实现上不需要额外写复杂逻辑最简单的方法是用一张可编程mask贴图灰度值代表不同的分区编号。在Pass B里采样这张mask然后根据分区ID去查对应的参数表。这套结构我后来还延伸到了衣服颜色、环境光响应等多种信息上一张mask管整条管线。5.2 动态光与Live2D/Spine的配合如果你是做Live2D或者Spine这类2D骨骼动画这套管线同样适用而且优势更大。因为2D骨骼动画的网格是不断形变的屏幕空间边缘光会跟着网格一起错误闪烁。但如果把动态扰动做成动画贴图并且绑定到骨骼节点的运动参数上光边就可以完美跟随关节运动还保持自身的流动规律。我试过在Spine里导出的角色上做这套效果只要把每个mesh的顶点色里预埋好法线方向简单粗暴地按角色的正侧面分方向Pass B照样能算出非常稳定的轮廓光。对挂载在Spine上的mesh来说不用特意做法线贴图顶点色足够用。5.3 整体曝光联动最后一个进阶玩法是把光边的强度参数绑定到场景的整体曝光或角色和摄像机的距离上。距离越近光边越弱距离拉远光边增强模拟远处角色和背景的分离感。这个我用一个公共滑块变量来驱动在摄像机推拉镜头时产生很强的空间层次感做演出脚本的时候特别好用。写在最后这套管线从踩坑到稳定前前后后花了我大概三周时间。最大的教训是不要一开始就想做全功能先把“扁平的语言”想明白再用技术去匹配语言而不是反着来。现在回看那些翻车方案全都是因为“想让光更亮更真实”而日系扁平要的其实是“想让光更像一层属于这个角色的颜色”。如果你也正在做日系扁平风格的动态角色我建议你先别急着下载各种边缘光插件打开自己的Shader把这篇文章里的思路落到代码上。第一版可能丑但多改几轮参数你会慢慢发现那圈光开始和你的角色说话而不是在你画面上大喊大叫。