从UE4到Unity:双叶高光在角色皮肤渲染中的BRDF实现与移植全流程

从UE4到Unity:双叶高光在角色皮肤渲染中的BRDF实现与移植全流程 前几天把一套在UE4里打磨了很久的角色皮肤材质迁到了Unity折腾了三个晚上才把高光表现基本还原。最麻烦的环节就是双叶高光——这个从日系风格化渲染流程里流行起来的处理方式在UE4里用材质蓝图叠两个高光分支就能实现到了Unity得从BRDF层自己重新拼。这篇文章就完整记录这次从UE4到Unity的双叶高光技术在角色皮肤渲染中的移植全流程包括当初为什么要把高光设计成“两叶”、从材质蓝图落到Shader代码时怎么翻译、移植时踩了哪些坑以及最终调参对标默认渲染效果的实操方法。适合正在做角色皮肤渲染或者想从UE4切换到Unity做风格化表现的技术美术、图形程序朋友参考。1. 为什么皮肤渲染需要“双叶高光”1.1 默认Lit做皮肤塑料感出在哪大部分人第一次在UE4里用默认Lit材质做角色皮肤都会遇到一个尴尬的情况光照一强皮肤表面出现一片又硬又亮的白色高光像涂了一层油光照一弱皮肤又变成哑光塑料完全没有通透感。问题不在地图精度也不在模型布线而是默认材质默认只用一个高光波瓣(lobe)去描述整个皮肤表面的反射。人的皮肤从物理上是分层的最外层是薄薄的表皮表面有一层油脂和细微纹理会产生一个相对尖锐、移动较快的高光皮下是真皮层里面有胶原纤维和血管光线打进皮肤后会发生大量散射再以比较“糊”的形态从周围漫射出来。这两种反射在方向上不同、宽度上也差很多倍。默认Lit材质只保留了一个GGX反射波瓣当我把粗糙度调到0.35左右能看到一个宽却发闷的高光调到0.15高光锐了但皮肤一下变得很“贼”很塑料。所以问题不是某一张贴图没画好而是默认材质本身只有“一个高光”没有办法同时在皮肤上表现出“表皮锐利反光”和“真皮层柔和泛光”两层信息。1.2 双叶高光的物理拆解窄叶与宽叶各管什么双叶高光的思路说穿了很简单把原来单个高光波瓣拆成两个独立波瓣分别控制宽度、强度和颜色再叠加输出。第一叶我习惯叫它窄叶。它的物理来源是表皮最外层的直接反射视觉上表现为高光点集中、边缘清晰、跟随光源移动快速。窄叶一般对应比较小的粗糙度参数比如0.2左右高光强度可以给得比较高因为这是人眼最容易捕捉到的“皮肤油润感”来源。如果这一叶没了皮肤会干得像粉笔。第二叶我习惯叫宽叶。它模拟的是光线进入表皮后在真皮层发生散射再穿出来的过程视觉上是高光周围一圈面积较大、亮度较低、过渡柔和的泛光。宽叶的粗糙度通常给到0.5以上甚至0.7这样它在高光点周围形成一个很大的“光晕区域”给皮肤带来通透感。很多项目里宽叶还会偏一点皮肤的红色或暖色因为真皮层有血液散射光会带上血色。为什么要拆成两个独立波瓣而不是简单地把一个GGX调宽一点因为单个GGX波瓣的形状是固定的要么中心锐利但衰减快要么中心很宽但整体稀薄。皮肤恰恰需要“中心很锐利、外部又很宽”这种不对称的反射分布一个波瓣根本拟合不出来。双叶高光本质上就是用两个不同粗糙度的波瓣去拟合真实皮肤反射曲线一个管中心尖峰一个管边缘拖尾。1.3 这套技术适合的场合以及它不解决的问题双叶高光并不是万能的。它适合的是风格化、半写实、尤其是以角色脸部、手部、肩颈为主要镜头对象的项目。比如日系游戏、二次元风格动作游戏、手办感渲染、甚至一些强调角色皮肤质感的卡牌立绘双叶高光都能带来非常明显的质感提升。但如果项目目标是写实级皮肤比如3A级电影化表现那双叶高光只能作为一个辅助层真正的核心还是要靠预积分次表面散射(Pre-Integrated Skin)或者完整的BSSRDF。双叶高光本质上是“高光层的拟合优化”它并没有解决光线穿透皮肤后的颜色透射问题也没有处理遮挡处皮肤泛红的效果。我做这套方案时也加了一张皮肤透射贴图但那是另外一个话题。移植前一定要如实评估你要解决的是高光形态问题不是做完整的皮肤次表面散射系统。2. 从UE4材质蓝图到Unity ShaderLab搬代码前必须想清楚的三件事2.1 两个引擎的材质语义和坐标系差异别急着打开Unity去写Shader先把两个引擎在材质语义上的差异摆清楚不然移植过去全是“看起来对但总觉得哪里不对”。UE4的材质输出节点比如Base Color、Metallic、Specular、Roughness它们的语义和Unity内建管线的Standard Shader有一些对应关系但不完全相等。对应关系大致如下设计内容UE4中的输出Unity内置管线对应注意点表面颜色Base ColorAlbedo色彩空间不同输出前需要统一高光反射颜色SpecularMetallic/Smoothness间接控制Unity默认PBR更强调金属度皮肤一般Metallic0粗糙程度RoughnessSmoothness1-Roughness反系数需要转换法线细节NormalNormal贴图通道左右手规则不同自发光EmissiveEmission单位不同需重新标定遮挡Ambient OcclusionOcclusion基本一致这里面最容易栽跟头的就是Roughness和Smoothness的转换。UE4里Roughness是“越亮越粗糙”Unity的Smoothness是“越亮越光滑”。如果你直接把UE4材质里的Roughness值扔进Unity的Smoothness高光会整个颠倒。我在做移植时是先换算出Smoothness1-Roughness再在材质面板上把范围校准一遍。坐标和空间也要提前对齐。UE4默认是左手系还是右手系这个问题网上说法很多但如果只谈Shader内计算更关键的是切线空间法线贴图的通道映射。UE4导入法线贴图时绿色通道默认朝上是Y方向Unity是绿色通道朝上是Y方向还是G方向实际上Unity是绿色通道指向Y两者看起来好像一样但真正的问题出现在UV翻转和双面渲染时的切线方向差异。后面我会专门讲这个坑。2.2 先把蓝图翻译成伪代码再落成HLSL直接对着材质蓝图去Unity里Node连Node很容易连成蜘蛛网而且稍微复杂一点的东西比如自定义高光函数、循环、分支UE4的材质蓝图写起来很灵活但翻译成Unity的Shader时如果不先抽象成伪代码一定会漏逻辑。我在UE4里实现双叶高光时材质蓝图大致是这种结构取Roughness作为基础粗糙度拆成两路一路给窄叶Roughness基础Roughness-0.1一路给宽叶Roughness基础Roughness0.3两路分别接一个高光反射节点两路高光分别乘各自强度和颜色最后Lerp或Add混合输出到Specular通道翻译成伪代码就是input: baseColor, roughness, normal, viewDir, lightDir narrowRough clamp(roughness - 0.1, 0.05, 0.4) wideRough clamp(roughness 0.3, 0.3, 0.8) narrowSpec SpecBRDF(normal, viewDir, lightDir, narrowRough) wideSpec SpecBRDF(normal, viewDir, lightDir, wideRough) finalSpec narrowSpec * narrowStrength * narrowColor wideSpec * wideStrength * wideColor output: baseColor finalSpec这一步做好之后写Unity的Shader就变成“把伪代码翻译成GPU代码”的机械工作不再需要反复看蓝图猜逻辑。我个人一直觉得跨引擎移植Shader最核心的能力不是背API而是能把节点流翻译成算法流。2.3 内置管线还是URP这个选择影响后面全部代码开始写UnityShader之前先确定项目用的是Built-in Render Pipeline还是URP。这个决定直接影响后面所有代码的写法。内置管线的优点是老资料多网上能搜到大量现成的Surface Shader写法而且和UE4材质蓝图的“表面输出”思路非常接近写起来容易理解。缺点是管线旧没有SRP Batcher做复杂材质时Draw Call压力大并且Unity官方已经开始弱化内置管线的新特性支持。URP的优点是可以使用SRP Batcher材质合批效率高支持Single Pass Instanced对移动端和PC端跨平台很友好。缺点是需要自己维护Pass、写HLSL时要用URP的输入结构体很多内置管线的内置函数用不了。我的选择是URP。因为现在做角色渲染基本都要跨手机端SRP Batcher带来的合批优势非常明显。如果你只是做一个纯PC端的演示项目用内置管线也完全够用。但不管你选哪条路最忌讳的是写着写着突然换管线因为光照函数、阴影宏、UnityObjectToClipPos等函数在两种管线里虽然同名但实现细节不同换管线等于重写Shader。3. 双叶高光核心Shader实现两叶拆分与能量分配3.1 一个可直接改用的双叶高光函数下面这段是我最后移植到URP里的简化版双叶高光核心代码只保留了高光计算部分不做完整的Pass展开方便你直接理解整个数学结构。half3 CalcDualLobeSpecular(half3 N, half3 H, half3 V, half3 L, half roughness, half3 specColor, half narrowStrength, half wideStrength) { half nh saturate(dot(N, H)); half nl saturate(dot(N, L)); half nv saturate(dot(N, V)); half narrowRough max(0.08, roughness - 0.12); half wideRough min(0.8, roughness 0.35); half D_narrow GGX_Distribution(nh, narrowRough); half D_wide GGX_Distribution(nh, wideRough); half V_smith SmithVisibility(nl, nv, wideRough); // 共用一个可见性项节省指令 half3 F FresnelSchlick(saturate(dot(H, V)), specColor); half3 specNarrow D_narrow * V_smith * F * narrowStrength; half3 specWide D_wide * V_smith * F * wideStrength; return specNarrow specWide; }为什么要共用同一个Smith可见性项因为双叶高光不是要完全模拟物理正确的双层反射而是要在性能和效果之间取平衡。如果每叶都单独算可见性项指令数增加不少但视觉差异很小人眼看不出多大变化。共用同一个可见性项尤其在移动端能省下不少ALU。GGX_Distribution和SmithVisibility可以直接用URP里的BRDF函数也可以自己实现。如果你用Unity的UniversalFragmentPBR函数内部已经有一整套PBR计算但那个函数不方便拆成双叶。所以我更建议自己控制高光部分漫反射直接沿用URP的Lambert整体影响不大。3.2 BRDF分布的选择Blinn-Phong、GGX与移动端平衡双叶高光里每一叶用什么BRDF分布是移植时必须做的一个决定。UE4里材质蓝图自带的高光节点是基于物理的默认类似GGX高光拖尾比较真实。Unity里如果直接用Blinn-Phong做双叶好处是算得快阴影边缘偏软适合比较卡通、圆润的皮肤风格。但Blinn-Phong在宽叶上的衰减不够长拖尾感不如GGX明显。GGX在高光拖尾和窄叶锐利度上都要好很多代价是计算量略高。不过现代GPU跑一个GGX分布函数并不贵关键看你在哪个平台跑。PC端无所谓移动端建议用half精度计算并且把循环和分支尽量换成编译器能自动优化的写法。我自己最后选择了方案是窄叶用GGX宽叶用GGX但宽叶不计算自遮挡。也就是宽叶只保留法线分布项D可见性项直接取常量0.5或者不乘。这样宽叶天然就是一个很柔和的泛光不会因为高光的几何衰减变得过暗。看起来不物理但视觉上更接近“真皮层散射出来的柔和光”而且省指令。3.3 能量守恒和半精度适配双叶高光最容易出现的问题两层高光叠加后总能量超过入射光导致皮肤过曝。尤其窄叶强度很高的时候两个波瓣叠在一起高光中心直接白成一片。我用了一个很朴素的能量守恒办法把漫反射部分乘以一个和总高光强度挂钩的系数。如果漫反射系数是diffuse总高光权重是specTotal那么最终输出是finalColor baseColor * lightColor * diffuse * (1 - saturate(specTotal * 0.8)) lightColor * dualSpec这样当双叶高光很强时漫反射会自动弱化一点点画面不会因为叠加而炸亮。这个系数不是物理推导出来的是我的经验值但实测下来比完全不控制要稳得多。移动端还要注意精度问题。Unity的Shader在手机GPU上有fp16数据通道我在写高光计算时把roughness、strength这些参数都设为half类型。其中一个较大的坑是当roughness非常小的时候GGX分母里出现exp的精度溢出鬼影出现。后来我把roughness做了一次clamp确保最小值不低于0.06问题就消失了。这些细节在PC上根本看不到但在Adreno和Mali上很容易翻车。4. 移植时最容易翻车的三个技术坑位4.1 法线贴图通道与切线空间的左右手问题移植完成后第一版效果高光位置总感觉偏了半厘米。尤其是脸颊颧骨位置的高光明明UE4里很自然到Unity里却往一侧偏。排查了一晚上最后定位到法线贴图的通道和切线空间左右手问题上。UE4在导入法线贴图时有一套自己的Normal Unpack逻辑默认情况下绿色通道朝上Unity的UnpackNormal默认是绿色通道也朝上。按说两者方向一致为什么高光会偏真正的问题出在模型导出时FBX里的切线方向。UE4和Unity在导入FBX时对切线的副法线方向计算规则不同特别是当模型在DCC软件里做过镜像UV之后切线空间左右手不一致。解决方式很朴素在Unity导入模型时把Tangents设置改为Calculate Legacy或者反过来根据引擎版本调整。然后还要检查法线贴图导入设置如果Texture Type改成Normal Map后高光仍旧偏位可以试试把法线贴图蓝色通道反转一下也就是在Shader里做n.z * -1。在我这个项目里问题最终是重算了模型切线后解决的。这里也想提醒一下如果你只是调Color空间、调节粗糙度怎么都highlight对不齐大概率不是光照参数问题而是切线空间不一致。别一开始就猛调高光参数。4.2 Gamma/Linear色彩空间差异导致高光亮度对不上UE4项目默认是线性工作流贴图在采样后会做sRGB解码。Unity如果项目设置为Gamma空间颜色计算方式和线性空间完全不同。我的项目在Unity里新建工程时默认是Linear但之前有同事把项目设成了Gamma导致同一个双叶高光参数在两边看到的亮度差半档。具体现象是在UE4里高光颜色值给到0.6就已经很亮到Unity里给同样的0.6却有点发灰。这是因为在Gamma空间下高光在屏幕空间经过sRGB编码后中间调会偏暗。解决方法是把Unity项目颜色空间设置为Linear同时把贴图导入时的sRGB选项和UE4里保持一致。如果你必须在Gamma空间工作那需要把高光颜色和强度整体调高约20%~30%但这会让某些光照角度下过曝所以我不推荐。另外UE4的高光反射颜色输出的是线性值Unity的Albedo也要求线性值但很多人直接从UE4里截取Base Color贴图再导到Unity时忘了把贴图类型改为sRGB最后整个皮肤颜色发暗。这种问题不一定是Shader出错先检查贴图导入设置。4.3 多光源和实时阴影下的高光噪点双叶高光在单一主光源下表现很漂亮但场景里如果加了多个点光源或聚光灯麻烦就来了每个光源都会对两叶高光做一次完整计算窄叶的高光在灯光密集的地方会闪烁、出现噪点尤其当光源的衰减范围小、光强高时噪点会非常明显。我在做角色展示场景时主光之外加了两个补光一个轮廓光。结果补光区域出现了一块块细碎的高光碎片像是皮肤表面撒了荧光粉。排查后发现是窄叶强度太高补光照度不够但高光函数在这种低照度下的响应依旧很剧烈。我最后的处理办法是给高光计算加了一个粗糙度相关的分阶当光源距离较远或亮度低于某个阈值时只计算宽叶不计算窄叶只有主光源或者强度大于阈值的灯光才计算完整的双叶高光。这样可以大幅减少自阴影区域和补光区域的噪点而主光下的皮肤质感不受影响。5. 拿默认Lit当对照组双叶高光的可视化调参流程5.1 准备一份参考图和固定的侧拍环境移植完成后最怕的不是Shader跑不起来而是参数调完“看着还行但说不上哪里不对”。我在调参前先做了一套对标流程省掉了很多盲目试参数的时间。首先找一张来自原UE4工程的参考截图最好是同一角色、同一光源方向、同一相机角度的渲染结果。然后在Unity里搭一个简单的白模场景放一个平行光作为主光源再放一个暗一点的补光保证角色头部和手部的高光区域能被完整看到。相机固定不要随意挪动所有调参过程都在同一个角度下进行。这一步看起来很简单但很多人忽略。没有固定参照凭感觉调高光参数很容易陷入“调一调觉得不错换个角度又发现不对”的死循环。5.2 先调窄叶、后调宽叶的调参顺序调参顺序我强烈建议固定成先调窄叶再调宽叶最后调颜色。原因很简单窄叶决定了高光是否存在、是否锐利、是否过曝是整个皮肤质感的地基。先把窄叶的Roughness和强度调到和参考图的高光中心区域基本一致再把宽叶加进来调整宽叶的扩展范围和亮度。如果先调宽叶很容易被周围一圈柔和的光晕影响判断高光中心到底锐不锐利反而看不清楚。实际操作时我习惯把宽叶强度先暂时降为0只保留窄叶用主光源从45度方向打下来观察脸颊、鼻梁、手背这些高光集中的区域。把窄叶Roughness从0.2开始往上下微调每调一次渲染一张图和参考图对比高光的大小和边缘硬度。等窄叶对上了再把宽叶强度从0.1往上加看高光外围的泛光区域是否足够柔和、有没有明显分界。5.3 经验参数范围与A/B对比方法以下参数范围是我在多个角色上试下来的经验值不一定适用于所有项目但可以作为起点参数窄叶建议范围宽叶建议范围说明Roughness0.08 ~ 0.250.45 ~ 0.75窄叶太细会显油腻宽叶太宽会发灰Strength强度0.6 ~ 1.20.15 ~ 0.45宽叶强度超过0.5容易导致整体过曝Specular Color偏白或淡皮肤色可微偏红/暖色宽叶加点红会模拟真皮层透光感A/B对比时我在Unlit Shader模式下面加了一个开关。具体做法是暴露一个半浮点属性_DebugMode当它等于1时只输出窄叶等于2时只输出宽叶等于3时输出完整双叶。在材质Inspector里拖动这个值就能非常直观地看出每一叶单独贡献的效果。这个调试开关留在正式发布版本里也不影响性能因为最终输出时不会走分支。我在实际项目里遇到的最难调的位置是脸颊和嘴唇过渡区域脸颊需要宽叶强一点显得通透但嘴唇区域如果宽叶太强看着就像油嘴。后来的做法是做了一张遮罩贴图把嘴唇区域的高光强度单独压低了40%。这也是双叶高光的一个常规操作——用一张控制贴图去局部控制两叶的强度。6. 后续扩展路径从皮肤到头发到更多风格化材质6.1 从皮肤复制到头发、手办和布料材质的思路双叶高光这套“两叶叠加”的思想也不止用在皮肤上只要是需要表现多层反射的材质都可以把同一套逻辑搬过去改参数。做头发时窄叶继续扮演快捷键表面高光宽叶则用来模拟头发丝之间的次级散射光。不过头发高光会受各向异性影响直接套用皮肤的GGX会出现高光方向感不对。我在把双叶高光迁移到头发时放弃了GGX改成Kajiya-Kay各向异性高光模型但依然保留了“两个波瓣”的结构一个管主高光的亮线一个管头发的柔光晕染。做手办感渲染或者ABS塑料材质时双叶高光也非常合适。手办皮肤材质的特点是高光锋利、边缘干净最好再加一点亮片质感。这种情况下窄叶Roughness可以调到0.05~0.1宽叶甚至可以只保留一丁点泛光用来衔接高光边缘。布料的逻辑反过来布料更多需要宽叶来表现织物表面的漫反射感窄叶强度要压得很低。6.2 双叶高光与次表面散射的叠加方式有人会问双叶高光和次表面散射是冲突还是叠加我的理解是它们解决的是不同层次的问题可以叠加但要注意加法顺序。正确的叠加逻辑是先算Lambert漫反射再叠加上宽叶高光模拟真皮层散射的泛光感然后加窄叶高光最后再单独计算透射光(Translucency)。我的Shader里把宽叶高光当作“准次表面层”它和真正的透射光不相互排斥。当你再想加透射光效果时只需要额外做一个基于厚度贴图的透射项加到最终颜色上。千万不要把宽叶高光当作次表面散射的替代品。宽叶能带来通透的泛光感但无法实现耳朵被背光打亮的红色透射效果。如果需要那种效果还是要引入厚度图参与计算。6.3 纹理控制高光区域与运行时开关优化双叶高光参数如果全部做成全局的角色多的时候会出现“千人一面”的问题。我在项目里增加了一张高光控制贴图把窄叶强度、宽叶强度和宽叶色温分别存到RGB三个通道里。这样美术能直接在贴图里控制额头、鼻头、脸颊区域的高光强度差异比调一个全局强度的可控性高很多。性能优化方面我在Shader里加了一个宏开关_ENABLE_DUAL_SPECULAR在不需要高光的角色或场景里可以只编译到单叶高光。移动端上这个开关能减少约15%的渲染指令数影响很可观。从UE4到Unity的移植最难的部分从来不是把一个函数抄过来而是理解它在原来的渲染环境里是怎样和光照、法线、空间、色彩配合的。双叶高光这套方案说到底是用“两层高光叠加”去模拟皮肤的多层反射想明白这个原理后无论引擎怎么换核心思路都能保持稳定。我后来又在Unity里给这张皮肤材质接上了自定义光照衰减和透射光效果比当初在UE4里还要完整一些这也是移植的另一层收获换一个环境等于重新审视一遍材质设计。