ARTICLE DETAIL

资讯详情

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

双叶高光皮肤渲染:从UE4到Unity URP的移植复刻与踩坑实录

双叶高光皮肤渲染:从UE4到Unity URP的移植复刻与踩坑实录 做角色皮肤渲染的人应该都遇到过同一个问题UE4或Unity的默认PBR高光打上去皮肤质感总是偏“塑料”。参数调到一定程度要么高光太窄像抹了油要么高光太宽像灯罩。后来我们在UE4项目里用了一套叫“双叶高光”的技巧把高光拆成两片一片负责锐利的皮肤表面油脂反射一片负责柔和的次表面散射感组合之后皮肤看起来会通透很多。最近项目需要把这套效果从UE4移植到Unity我把完整过程捋了一遍包括原理、节点拆解、Shader复刻、常见坑位整理成这篇文章。适合正在做角色皮肤渲染、不想继续在PBR参数里挣扎的TA和图形程序员参考。1. 双叶高光原理拆解与移植前准备1.1 双叶高光是怎么回事双叶高光这个叫法不同团队里解释略有不同我们沿用最常见的一种理解“双叶”形容的是两层高光一层像植物叶片表面那种硬朗的镜面反射一层像叶片背面透光时那种柔和的光晕。对应到皮肤渲染上就是物理上真实存在的两种反射行为。皮肤并不是单一反射表面。最外层是角质层表面还有一层薄薄的油脂和水分混合膜这层东西会把光线集中反射出去形成比较锐利的高光。光线穿过表皮之后在真皮层里会被胶原纤维和血管散射一部分光线又从皮肤内部“透”出来形成比较宽泛的次级反射光。常规PBR用一个GGX高光加一个粗糙度很难同时模拟这两个现象。粗糙度调低了高光整体锐利皮肤缺少那种“白里透光”的柔和感粗糙度调高了整体高光过宽又没有皮肤表面那一点点润泽的反光。双叶高光的思路就是各管各的。两片高光都基于半程向量和法线点乘但一个用很高的指数压缩出锋利小亮点另一个用很低的指数铺开成柔和大光斑最后用权重混合。公式大致是锐利层高光 pow(saturate(dot(N, H)), 高指数) × 高光强度柔光层高光 pow(saturate(dot(N, H)), 低指数) × 高光强度最终高光 lerp(柔光层, 锐利层, 混合权重)有一点必须说明双叶高光不是次表面散射SSS它只是用高光造型在视觉上模拟皮肤的通透感。真正的SSS需要额外的透射计算和采样双叶高光胜在成本低、可控性强尤其适合日式风格化角色和移动端项目。1.2 UE4与Unity的渲染差异对照移植这件事最怕的是拿着UE4的材质节点在Unity里找一个同名节点直接连。两个引擎的渲染模型看似接近实际上从光源获取方式到输出节点的语义都有差异。我把核心差异整理成一张对照表后面所有复刻工作都以这张表为基础。对比项UE4UnityURP / Built-in材质编辑器Material节点编辑器蓝图式Shader Graph节点式或手写Shader光源方向LightVector节点Shader Graph的Main Light Direction节点手写Shader用_WorldSpaceLightPos0或GetMainLight视角方向CameraVector节点View Direction节点手写Shader用GetWorldSpaceNormalizeViewDir半程向量需要自行Add Normalize同样需要自行计算没有现成HalfVector节点法线贴图空间默认切线空间默认切线空间导入设置需标记为Normal Map高光工作流Metallic / Specular两套URP有Metallic / Specular两套Built-in也有阴影信息材质节点里容易取到Shader Graph需接入Main Light Shadow手写Shader要自己处理shadowAttenuation从这张表能看出来双叶高光的核心数学没有变但“在哪拿方向、在哪输出颜色”完全不一样。所以移植的关键不是逐节点翻译而是把整个材质行为拆出来用Unity的方式重写一遍。1.3 移植前先想清楚这4个问题动工之前最好先问自己几个问题不然很容易做到一半发现方案选错了。第一个问题目标渲染管线是什么如果是比较老的项目还在用Built-in管线Shader Graph和URP Shader都无法直接用如果项目已经切到URP就要按URP的API来写。我们这次是URP项目所以下面所有代码和节点操作都以URP为例。HDRP又是另一套API不建议在HDRP里直接用URP Shader。第二个问题双叶高光要不要保留PBR参数我的建议是保留。皮肤本质是绝缘体Metallic固定为0Smoothness还承担了基础高光形状控制。双叶高光应该作为高光层的“增强”而不是把PBR高光完全扔掉。否则遇到多光源、环境反射和阴影时效果会非常不自然。第三个问题需要支持多光源还是主光源就够很多风格化角色只吃主光双叶高光只用主光方向就够了但遇到切换光源数量容易出问题。如果希望严谨一点就要把双叶高光放到光照循环里或者走引擎的Specular通道。第四个问题是否需要移动端兼容高指数的pow在移动端half精度下容易出现色带和闪烁这个问题在下面“踩坑实录”里会详细说。如果目标是移动端参数上限、精度关键字都要提前规划。2. UE4侧材质节点拆解把原Shader的“配方”挖出来2.1 用节点复现双叶高光如果你手上还有UE4工程最直接的办法就是把原材质截屏存档再逐步拆解。我描述一下典型节点图长什么样方便没有原始工程的读者理解。以Lit材质为例材质域选Surface光照模型选Default Lit或Subsurface Profile都可以。在材质图里我们通常这样搭用Normal Vector节点取世界空间法线。用Light Vector节点取的是场景主光方向用Camera Vector节点取视角方向。把Light Vector和Camera Vector相加再接Normalize节点得到半程向量H。用Dot节点计算法线和H的点乘接Saturate。复制这个结果一个接Power节点指数设到64到256之间作为锐利层另一个接Power节点指数设到4到16之间作为柔光层。两层分别乘强度系数再用Lerp按权重混合。如果觉得节点图太占面积直接在Custom节点里写几行HLSL也可以效果一样float3 H normalize(LightVector CameraVector); float nh saturate(dot(VertexNormalWS, H)); float sharp pow(nh, Leaf1Power); float broad pow(nh, Leaf2Power); return lerp(broad, sharp, LeafBlend);在UE4里这个返回值一般有两种接法一种是接到Specular引脚让引擎按高光流程走另一种是接到Emissive Color作为发光项直接叠加。前者物理响应更正确后者风格化控制更强。我们最终选择了接到Specular引脚并且通过一个参数开关控制是否启用双叶高光方便A/B对比。2.2 UE4皮肤材质中双叶高光与PBR的搭配原项目里角色皮肤并不是一个纯PBR材质也不是一个纯风格化材质而是两者混搭。基色贴图驱动Base ColorSmoothness用贴图控制Metallic直接给0。双叶高光作为高光层的“附加贡献”在一个Lerp里和默认PBR高光做混合混合系数由一张角色专属遮罩贴图的R通道提供。为什么要这么搭因为PBR高光在高光点上是准确的但皮肤那种“润而不油”的中间过渡区域PBR很难单独表现。双叶高光补的正是这部分视觉信息。注意不要让两层高光都独立直接输出那样高光强度会叠加失控尤其是脸颊、鼻尖这些高光集中区域特别容易过曝。建议在UE4里先把两层高光的原始强度调到0.3到0.8之间再通过材质实例微调而不是图省事直接加到2.0。这里必须提一下UE4的Smoothness工作流。UE4里很多皮肤贴图习惯把Smoothness直接压在Alpha通道然后用Constant节点读取。移植到Unity时如果贴图本身没有变UE4的Smoothness和Unity的Smoothness数值不能直接画等号因为两边的粗糙度到高光宽度的映射有细微差别。别嫌麻烦一定要在Unity里重新目测校准。2.3 移植前需要记录的最小参数集很多人移植经验少容易犯一个错误盯着节点一个个复制结果漏了某个贴图通道或者参数。我的建议是在动Unity之前先写一份“移植信息采集表”把原材质的输入输出全部抽出来归档。基础色贴图、法线贴图路径及导入设置。Smoothness来源是贴在Alpha通道还是单独一张图第一层高光强度、指数。第二层高光强度、指数。混合权重来源是纯数值还是贴图控制如果用贴图是哪张贴图的哪个通道高光颜色是纯白还是乘了肤色如果乘了肤色是乘BaseColor还是单独一张高光颜色贴图是否接入了SSS项SSS的强度和宽度参数是多少是否和Rim Light边缘光混在一起把这些参数整理成表之后在Unity里重建Properties就非常快了。我们最后只花了半天就把参数全部对应上剩下时间都在调视觉差异。3. Unity侧复刻从Shader Graph到手写Shader3.1 用Shader Graph快速验证效果进入Unity之后我建议先不要直接上手写代码先用Shader Graph快速搭一个原型确认思路没有跑偏。URP下创建一个PBR Shader Graph然后按下面这些步骤连节点。第一步把BaseColor和Normal贴图接入PBR Master节点的Base Color和Normal引脚。法线贴图的导入设置必须改成Normal Map否则着色器里看到的效果是错的。第二步拿光照方向。在Shader Graph里URP环境用Main Light Direction节点Space选World。如果项目还在Built-in管线就没有这个节点需要自己通过Light Data子图或者手写Custom Function节点获取_WorldSpaceLightPos0。第三步拿视角方向。直接用View Direction节点Space选World。第四步计算半程向量。Shader Graph里没有HalfVector节点需要Add之后接Normalize。这里要注意Add的两个输入都是三维向量别把精度设置得太低。第五步计算NdotH。用Dot节点输入法线和半程向量再接Saturate。第六步复制这个NdotH接两个Power节点一个指数设高一个指数设低各自乘强度再用Lerp混合。混合权重可以先用一个Slider节点占位后面再换贴图。第七步把这个混合结果接到PBR Master节点的Specular引脚或者接到Emission作为风格化输出。接到Specular更符合物理遇到后期处理不会出大问题。Shader Graph的好处是迭代快适合验证两层高光的比例和整体感觉。但项目落地的时候我还是推荐手写Shader原因很简单Shader Graph生成的代码开销不可控而且跨版本升级经常出现节点行为变化手写HLSL虽然麻烦一点胜在稳定可控。3.2 手写URP Shader完整代码与关键函数解析下面这份代码是在URP下可用的基础版本我们在项目中一直沿用只是把参数名换成了项目规范。代码里省去了一些宏定义保留了双叶高光最核心的计算逻辑适合作为复刻的起步模板。Shader Custom/DoubleLeafSkin { Properties { _BaseMap (Base Map, 2D) white {} _BaseColor (Base Color, Color) (1,1,1,1) _NormalMap (Normal Map, 2D) bump {} _Leaf1Power (Sharp Leaf Power, Range(1,256)) 96 _Leaf1Strength (Sharp Leaf Strength, Range(0,2)) 0.8 _Leaf2Power (Broad Leaf Power, Range(1,256)) 8 _Leaf2Strength (Broad Leaf Strength, Range(0,2)) 0.6 _LeafBlend (Leaf Blend, Range(0,1)) 0.5 } SubShader { Tags { RenderPipelineUniversalPipeline RenderTypeOpaque } Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl CBUFFER_START(UnityPerMaterial) float4 _BaseColor; float4 _BaseMap_ST; float4 _NormalMap_ST; float _Leaf1Power; float _Leaf1Strength; float _Leaf2Power; float _Leaf2Strength; float _LeafBlend; CBUFFER_END TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); TEXTURE2D(_NormalMap); SAMPLER(sampler_NormalMap); struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float4 tangentOS : TANGENT; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float3 normalWS : TEXCOORD1; float4 tangentWS : TEXCOORD2; float3 viewDirWS : TEXCOORD3; float3 positionWS : TEXCOORD4; }; Varyings vert(Attributes input) { Varyings output (Varyings)0; VertexPositionInputs positionInputs GetVertexPositionInputs(input.positionOS.xyz); VertexNormalInputs normalInputs GetVertexNormalInputs(input.normalOS, input.tangentOS); output.positionCS positionInputs.positionCS; output.uv TRANSFORM_TEX(input.uv, _BaseMap); output.normalWS normalInputs.normalWS; output.tangentWS float4(normalInputs.tangentWS, input.tangentOS.w); output.positionWS positionInputs.positionWS; output.viewDirWS GetWorldSpaceNormalizeViewDir(positionInputs.positionWS); return output; } half4 frag(Varyings input) : SV_Target { half4 baseMap SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv) * _BaseColor; half3 normalTS UnpackNormal(SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, input.uv)); half3x3 tangentToWorld half3x3( input.tangentWS.xyz, cross(input.normalWS, input.tangentWS.xyz) * input.tangentWS.w, input.normalWS ); half3 normalWS normalize(TransformTangentToWorld(normalTS, tangentToWorld)); Light mainLight GetMainLight(); half3 lightDir normalize(mainLight.direction); half3 viewDir normalize(input.viewDirWS); half3 halfDir normalize(lightDir viewDir); half NdotH saturate(dot(normalWS, halfDir)); half NdotL saturate(dot(normalWS, lightDir)); half sharpLeaf pow(NdotH, _Leaf1Power) * _Leaf1Strength; half broadLeaf pow(NdotH, _Leaf2Power) * _Leaf2Strength; half specular lerp(broadLeaf, sharpLeaf, _LeafBlend); half shadow mainLight.shadowAttenuation; half3 lighting mainLight.color * NdotL * shadow; half3 ambient SampleSH(normalWS); half3 finalColor baseMap.rgb * (ambient lighting) specular * mainLight.color; return half4(finalColor, 1.0); } ENDHLSL } } FallBack Universal Render Pipeline/Lit }这份代码里几个关键函数的用途要说明一下。GetVertexPositionInputs和GetVertexNormalInputs是URP封装好的空间变换辅助函数分别负责把模型空间数据转成世界空间数据省去手动拼矩阵的麻烦。GetMainLight()在URP里拿到的是主光源的模拟结构体里面有方向、颜色和阴影衰减前提是项目里已经配置好URP的Lighting设置。GetWorldSpaceNormalizeViewDir返回的是从顶点指向相机的归一化方向。片段着色器里最核心的几行就是NdotH的计算和两层pow。一个是高指数负责锐利的小亮点一个是低指数负责宽泛的柔光层用Lerp混合。如果你想直接叠加而不是过渡可以把Lerp换成加法但那样做要注意过曝我个人不太推荐。最后两行是输出颜色。注意高光不是直接加到最终颜色上就完事要乘以主光颜色且要叠加在环境光和漫反射之上这样看起来才是一个受光照影响的高光而不是一块死白。3.3 双叶高光与Unity PBR的整合策略手写Shader的高自由度也带来了一个问题怎么和Unity的PBR光照、阴影、多光源体系融合我整理出三种常用方案项目里也分别测试过。方案A把双叶高光直接合成到最终颜色里类似上面代码的做法。优点是完全可控高光形状和强度精确缺点是多光源时要自己处理而且阴影衰减忘乘就会出现“发光脸”。方案B把双叶高光结果输入到PBR Master节点的Specular通道。这种方式让高光和引擎的PBR模型绑定多光源、阴影和环境光都会自动参与计算风格化程度稍弱但物理正确性最高。方案C把双叶高光作为Emission发光项输出。这种方案能让高光在暗部、阴影里依然很亮适合赛璐璐风格或者三渲二效果。代价是阴影下皮肤高光也不会消失看起来会有一点“自发光”的感觉。整合方案多光源支持阴影响应风格化强度实现复杂度A 直接合成最终颜色弱需手写循环手动乘shadowAttenuation中低B 走Specular通道强引擎自动处理引擎自动处理中低低C 走Emission通道强但不受光源影响不受阴影影响高低我们最终选择的是方案B加方案A的混合主光源下用方案A做风格化高光其他光源使用方案B让引擎自动处理。这样既能保证角色在重要镜头下的观感又不会在大场景里因为多光源切换而出现高光闪烁。4. 移植过程踩坑实录6个高频问题与解决4.1 高光位置偏移罪魁祸首是光源方向第一次在Unity里复刻双叶高光时我们遇到的最匪夷所思的问题就是高光位置和UE4里完全对不上本该在脸颊右侧的高光跑到鼻梁中间。排查了半天最后发现是光源方向的正负号问题。UE4的LightVector节点在某些情况下返回的是指向光源的方向而Unity手写Shader里_WorldSpaceLightPos0在URP下可能因为w分量不同被当成点光或平行光。在URP的GetMainLight()里mainLight.direction已经是片元到光源的方向所以我的代码里直接用了它。如果你的项目还在Built-in管线记得检查_WorldSpaceLightPos0的w分量w为0表示平行光方向直接取xyzw为1表示点光源方向需要用位置相减。这一步错了高光位置就会千奇百怪。4.2 法线贴图颜色异常与空间不一致另一个常见坑是法线贴图和法线空间不一致。UE4里法线贴图默认是切线空间导入时引擎内部会自动处理。Unity也一样右键导入贴图Texture Type必须选Normal Map否则UnpackNormal出来的值完全是错的。Shader Graph里还有一个容易忽略的点Normal Vector节点的Space如果选成了Object或者Tangent计算出来的NdotH就有偏差高光的形状会整体扭曲。我一般习惯把所有参与双叶高光计算的方向全部统一到World Space包括法线、光源方向、视角方向。节省排查时间也更直观。4.3 高光色带与半进度精度不足把双叶高光档位调到比较高的指数之后某个低端Android测试机上出现了明显的等高线色带尤其是128以上的Power值表现尤其严重。原因很直接在部分移动GPU上Fragment Shader里默认使用half精度而pow函数对低精度的敏感度非常高数值一旦逼近0再求高次方就会被整段“压平”形成分像素色阶。解决办法有三个优先级从高到低。一是把参与pow计算的关键变量改成float精度代价是移动端性能稍微下降一点。二是限制_Leaf1Power最大值不建议超过256。三是用遮蔽函数处理高光边缘比如用fwidth加smoothstep做抗锯齿虽然不能消除底层精度问题但视觉上能大幅减轻色带。4.4 阴影下高光泛白这个问题在方案A里特别明显角色走到阴影区域之后漫反射暗下去了但高光还是亮的出现一种“皮肤在发光”的诡异感。原因很简单代码里只乘了NdotL没有乘shadowAttenuation。修正方式就是在计算光照时把阴影衰减乘进去。上面代码里有一行half shadow mainLight.shadowAttenuation;就是干这个的。如果你用Shader Graph最简单的方式是接入Main Light Shadow节点把阴影衰减和高光强度相乘。走了这一步阴影下的皮肤高光就会自然收敛。4.5 移动端高指数Power的性能与精度取舍移动端对高指数pow的计算量其实没那么敏感真正的问题是精度。以我实测的几款机器来看_Leaf1Power在64到128之间时画面和性能都比较平衡。一旦超过256低端机上不仅色带明显还可能出现高光闪烁。如果你希望高光边缘更锐利又不想堆Power有个技巧是把两层高光做乘法叠加。比如把sharpLeaf和broadLeaf相乘再乘一个强度系数等于用低指数实现类似高指数的锐利边缘。我们项目最终用的就是pow加乘法混合既能控制精度又保留了皮肤的透亮感。4.6 两层高光边界不自然两层高光混合权重的Bug比想象中更容易踩。直接Add会过曝Lerp又容易让两层边界出现一条明显的“分界线”。原因是两层高光的宽度差异过大在过渡区域里宽度跳变太剧烈。解决方法是给混合权重加一个基于NdotH的平滑过渡。简单一点可以额外计算一个NdotH_smooth smoothstep(0.0, 1.0, NdotH)然后把它作为Lerp的混合系数的一部分。这样从柔光层过渡到锐利层时边界会柔和很多。更进阶的做法是用贴图控制这个放到后面调参部分说。4.7 常见问题速查表问题可能原因解决方法高光位置偏移光源方向正负号或w分量判断错误检查Main Light Direction确认是否指向光源法线导致高光扭曲法线贴图未标记为Normal Map或空间不一致导入设置改Normal Map计算统一到World Space高光滑出现色带half精度不足或Power指数过高提高精度限制指数最大值边缘加抗锯齿阴影下高光泛白未乘shadowAttenuation光照计算中乘以阴影衰减移动端高光闪烁高指数Power在低精度下不稳定用低指数乘法模拟或限制参数范围两层高光边界生硬混合权重跳变过快用smoothstep平滑权重或使用贴图控制5. 调参心得与不同风格应用5.1 核心参数含义与调节方向双叶高光的参数不多但每个参数都直接影响皮肤的“性格”。在项目里我习惯按照这样一套逻辑来调。_Leaf1Power控制锐利高光的集中程度。数值越高高光点越小越亮适合做出那种“湿润嘴唇”或者“鼻尖一点光”的效果。写实方向建议64到128风格化方向可以拉到192到256之间但超过256容易引发移动端精度问题。_Leaf1Strength控制锐利高光的整体强度。这个值不宜过高通常0.3到0.8之间。因为锐利层的面积小一旦强度超过1.0在高光核心区域会非常刺眼后期校色都救不回来。_Leaf2Power控制柔光层的延展范围。数值越低柔光铺得越开皮肤的粗糙感和厚度感越强。写实角色建议4到16偏CG风格可以升高到24到32太高就失去“柔光”的意义了。_Leaf2Strength是柔光层亮度建议0.2到0.6之间。柔光层本质上是一种皮肤内部的“透光错觉”太亮会像皮肤被灯从下面照着很奇怪。_LeafBlend是两层之间的权重。0表示完全使用柔光层1表示完全使用锐利层中间值是两层混合过渡。写实方向建议0.4到0.6日系风格建议0.2到0.3因为日系皮肤强调大面积柔和的受光感锐利高光只保留一小点。5.2 用贴图控制混合权重把_LeafBlend从一个Slider换成贴图之后效果会有一个质的提升。我们项目里用了一张“皮肤遮罩贴图”R通道存高光强度倍率G通道存两层高光的混合权重。脸颊位置G通道值高一点这样柔光感更强鼻梁和额头G通道值低一点锐利高光更明显。这种做法的好处是可以对脸部不同区域做非常细的控制。比如下眼睑需要一点点锐利高光来提神但面积过大的话会显得油嘴唇需要高强度的锐利层和低强度的柔光层才能表现唇纹和湿润感。这些细节很难用一个全局参数调出来遮罩贴图是性价比最高的方案。5.3 和SSS、Rim Light的后续组合双叶高光不是皮肤渲染的终点。在原项目里这层高光还要和次表面散射、边缘光做组合。做法是先把SSS的Wrap漫反射计算出来作为漫反射项的输入双叶高光的锐利层和柔光层各自独立先混合再乘一个由SSS强度生成的遮罩避免在高光区域出现过度叠加。Rim Light边缘光和双叶高光组合时尤其要注意主光照方向。如果边缘光方向来自背光侧而双叶高光的半程向量又指向相机两个效果会互相抢视觉注意力看起来脸上同时有两个光源。我的习惯是把边缘光的强度做成NdotV的函数在脸部正对相机时衰减掉大部分只保留轮廓处的微弱光边。最后说点个人体会。移植最怕的不是Shader本身而是把UE4里那套“手感”丢掉。我在Unity里重建完双叶高光后第一版拿到的画面总觉得不对劲后来发现是UE4默认材质里Smoothness工作流和URP不完全一致导致同一张贴图在两个引擎里看起来不是一个亮度。类似这种引擎差异一定要在移植文档里记录清楚。如果你也在做角色皮肤渲染的跨引擎移植建议先抽一天时间把两个引擎的材质参数整理成对照表再动手改Shader踩坑会少很多。后面我还会继续分享这个双叶高光在Unity里接SSS和Rim Light的进阶做法感兴趣的话可以持续关注。
返回列表