1. 项目概述:告别穿模,让发丝在URP中“活”起来
在Unity里做角色,头发绝对是个老大难问题。尤其是当你兴致勃勃地把精心雕刻的高模导入引擎,结果角色一动,头发就像一块僵硬的塑料片,要么直接穿进肩膀里,要么和身体发生诡异的交错,瞬间让整个角色的质感掉到谷底。更别提那些追求真实感或风格化表现的项目了,传统的Lambert或Blinn-Phong光照模型处理头发,出来的效果要么像油腻的湿发,要么就是一片毫无细节的色块,根本表现不出发丝应有的那种丝缕分明的光泽感和通透感。
这个问题困扰了我很久,直到我开始在Unity的通用渲染管线(URP)下折腾Kajiya-Kay模型。你可能听说过这个经典的发丝渲染模型,它早在1989年就被提出来了,核心思想就是把头发当成一个各向异性的高光圆柱体来处理。听起来有点抽象?简单说,普通模型的高光是一个“点”或一个“圆斑”,而头发的高光应该是一条沿着发丝方向的“亮线”。Kajiya-Kay模型就是通过一个巧妙的数学公式,模拟了这种沿着切线方向拉伸的高光,从而让头发呈现出丝绸般顺滑的光泽。
为什么非得在URP里做?因为URP已经成为Unity现代项目的事实标准。它轻量、高效,对移动端和跨平台支持友好。但URP的Shader编写环境和内置管线(Built-in)有些不同,很多经典的头发Shader代码不能直接搬过来用,需要针对URP的Shader Library和光照模型进行适配。网上能找到的完整、可用的URP版Kajiya-Kay Shader并不多,要么是代码片段不全,要么是光照计算有问题,导致效果大打折扣。
所以,我花了相当一段时间,把Kajiya-Kay模型在URP下重新实现了一遍,并且解决了头发常见的穿模排序问题。这篇文章,就是把我趟过的路、踩过的坑,以及最终调出满意效果的完整Shader代码和思路,毫无保留地分享出来。无论你是独立开发者,还是技术美术,只要你在URP项目里受够了头发的折磨,想实现更真实、更风格化的发丝渲染,这篇内容都能给你一套可以直接“抄作业”的解决方案。我们会从原理拆解开始,一步步走到完整的Shader实现和模型制作技巧,最后还会聊聊怎么优化性能。放心,我会尽量用大白话把事儿讲明白。
2. 核心思路拆解:Kajiya-Kay模型与URP适配之道
2.1 Kajiya-Kay模型到底在算什么?
要写好Shader,不能只当代码的搬运工,得先明白它背后的物理(或者说,视觉)原理。Kajiya-Kay模型的核心公式其实并不复杂,它主要贡献了一个用于计算各向异性高光的项。我们平常用的漫反射(Lambert)和镜面反射(Blinn-Phong)都是“各向同性”的,意思是无论你从哪个方向看,高光形状都差不多。但头发不一样,一根发丝可以看作一个微小的圆柱体,光线照上去,其反射特性强烈依赖于你的观察方向与发丝切线方向的关系。
Kajiya-Kay模型的关键输入是三个向量:切线(Tangent)、光线方向(L)和视线方向(V)。它先计算光线方向L和视线方向V在垂直于法线(N)的平面上的投影,分别记为T和B。然后,它认为高光的强度与(T · B)这个点积值的某种幂函数有关。经典的公式简化后,高光项常常表达为:Specular = pow(sin(T, B), SpecularPower)的一种变体。 实际操作中,我们常用一个更实用的近似:spec = pow(max(0, dot(N, H)), exp),但这里的H不是半角向量,而是一个由切线和视线、光线方向构造的“各向异性半角向量”。不过在现代实现里,我们更倾向于使用一个更直观的思路:用切线和副法线(Binormal)构建一个切线空间,然后在这个空间里计算一个基于方向的高光。
具体到Shader里,我们通常会这样做:
- 构建切线空间基向量:利用模型顶点的法线(Normal)和切线(Tangent),通过叉乘计算出副法线(Binormal)。
- 将光线和视线向量变换到切线空间:这样,我们就可以在发丝的“局部坐标系”下讨论问题。
- 计算各向异性高光:核心是计算光线方向在切线方向上的分量,以及视线方向在切线方向上的分量,通过它们的某种组合(如点积、正弦函数)来模拟沿着发丝方向的高光条带。一个常见的实现是:
float TdotH = dot(tangentDir, halfVector); float BdotH = dot(binormalDir, halfVector); float anisotropy = max(0, sin(atan2(BdotH, TdotH))); specular = pow(anisotropy, _Gloss)。
这个模型生成的高光不是圆形的,而是沿着发丝方向拉伸的细长条,这正是头发丝绸感的来源。同时,它还能通过调整参数,模拟出发梢的“彩虹色”效果(次级高光),虽然Kajiya-Kay本身对多重散射模拟不足,但对于大多数实时应用来说,视觉效果已经足够惊艳。
2.2 在URP中实现的关键挑战与策略
在Built-in管线里,你可以写一个Surface Shader,Unity帮你处理很多光照和阴影的宏。但在URP里,我们主要写的是URP Lit Shader或Unlit Shader,并通过自定义的Lighting Function来集成复杂光照模型。对于Kajiya-Kay这种非PBR模型,我们通常选择扩展URP的SimpleLit光照模型,或者干脆从Unlit开始自己构建整个光照流程。这里我推荐后者,因为控制更彻底,也更容易理解数据流向。
第一个挑战是获取光照信息。URP通过Lighting.hlsl等库函数提供了一套结构化的光照数据。我们需要在片元着色器(Fragment Shader)中,使用GetMainLight()获取主方向光信息,使用GetAdditionalLightsCount()和GetAdditionalLight()获取额外的逐像素光。这比Built-in里直接访问_WorldSpaceLightPos0要规范,但也需要适应。
第二个挑战是着色器变体(Shader Variants)管理。URP为了性能,会为着色器编译不同光照和阴影配置的变体。我们需要在Shader的HLSLPROGRAM块中,使用#pragma multi_compile指令来声明我们需要的变体,例如处理主光阴影、附加光阴影等。如果声明不全,在特定条件下(如开启阴影)Shader可能会出错或回退。
第三个挑战是渲染队列(Render Queue)与Alpha混合。真实的头发往往是半透明的,尤其是发梢。为了处理透明和深度排序问题,防止穿模,我们必须仔细设置渲染队列。通常,不透明头发部分使用“Geometry”队列(如2000),而半透明部分使用“Transparent”队列(如3000)。更重要的是,我们需要修改深度写入(ZWrite)和深度测试(ZTest)。一个常见的技巧是:对于半透明部分,关闭深度写入(ZWrite Off),但保持深度测试为小于等于(ZTest LEqual)。这样,头发能正确遮挡后面的物体,但多根发丝之间或头发与身体之间,不会因为深度写入而相互“遮挡”导致排序错误。更高级的做法会用到双通道渲染(Two-Pass):第一遍用不透明Shader写入深度(只写深度,不输出颜色),第二遍用半透明Shader进行实际渲染并基于之前的深度进行测试,这能有效解决半透明物体的深度排序问题。
第四个挑战是性能。各向异性高光计算比各向同性更耗性能。我们需要在Shader中尽可能地将计算移到顶点着色器(Vertex Shader),或者利用插值器(interpolators)传递计算好的向量。同时,要警惕在片元着色器中进行过多的复杂数学运算,如sin、pow、atan2等。
3. 完整Shader代码解析与逐行实现
理论说再多,不如直接看代码。下面就是我基于URP 12(或更新版本)编写的一个完整的Kajiya-Kay头发Shader。它包含了不透明和半透明混合两种渲染模式,并处理了多光源光照。我会把代码分成几个关键部分,并加上详细注释。
注意:在URP中创建Shader时,请确保模板选择“URP Lit Shader”或“Blank Shader Graph”,然后清空内容粘贴以下代码。以下代码为
.shader文件内容。
Shader “Custom/KajiyaKayHairURP” { Properties { // 基础纹理与颜色 [MainTexture] _BaseMap(“Albedo (RGB)”, 2D) = “white” {} [MainColor] _BaseColor(“Base Color”, Color) = (1,1,1,1) // 各向异性高光参数 _SpecularColor(“Specular Color”, Color) = (1,1,1,1) _SpecularPower(“Specular Power”, Range(1, 512)) = 100 _SpecularScale(“Specular Scale”, Range(0, 2)) = 0.5 // 次级高光(模拟彩虹色) _SecondarySpecularColor(“Secondary Spec Color”, Color) = (1,0.8,0.5,1) _SecondarySpecularPower(“Secondary Spec Power”, Range(1, 512)) = 50 _SecondarySpecularScale(“Secondary Scale”, Range(0, 2)) = 0.2 // 法线贴图(可选,用于增加细节) [Normal] _BumpMap(“Normal Map”, 2D) = “bump” {} _BumpScale(“Normal Scale”, Float) = 1.0 // 透明度控制 _Cutoff(“Alpha Cutoff”, Range(0.0, 1.0)) = 0.5 [Enum(UnityEngine.Rendering.BlendMode)] _SrcBlend(“Src Blend”, Float) = 1 [Enum(UnityEngine.Rendering.BlendMode)] _DstBlend(“Dst Blend”, Float) = 0 [Toggle(_ALPHATEST_ON)] _AlphaClip(“Alpha Clip”, Float) = 0.0 } SubShader { Tags { “RenderType” = “Opaque” “RenderPipeline” = “UniversalPipeline” “IgnoreProjector” = “True” } LOD 300 // ———————————————————————————— // Pass 0: 不透明/裁剪渲染通道 // ———————————————————————————— Pass { Name “ForwardLit” Tags { “LightMode” = “UniversalForward” } Blend [_SrcBlend] [_DstBlend] ZWrite On Cull Back HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile _ _MAIN_LIGHT_SHADOWS #pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS #pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS #pragma multi_compile_fragment _ _SHADOWS_SOFT #pragma multi_compile_fragment _ _SCREEN_SPACE_OCCLUSION #pragma multi_compile _ LIGHTMAP_ON #pragma multi_compile _ DIRLIGHTMAP_COMBINED #pragma multi_compile _ _MIXED_LIGHTING_SUBTRACTIVE // Alpha测试关键字 #pragma shader_feature _ALPHATEST_ON #include “Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl” #include “Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl” #include “Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl” struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float4 tangentOS : TANGENT; // 切线信息至关重要 float2 texcoord : TEXCOORD0; float2 lightmapUV : TEXCOORD1; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float3 positionWS : TEXCOORD1; float3 normalWS : TEXCOORD2; float3 tangentWS : TEXCOORD3; // 世界空间切线 float3 bitangentWS : TEXCOORD4; // 世界空间副切线(由法线和叉乘得到) DECLARE_LIGHTMAP_OR_SH(lightmapUV, vertexSH, 5); // 光照贴图或球谐 float3 viewDirWS : TEXCOORD6; #ifdef _MAIN_LIGHT_SHADOWS float4 shadowCoord : TEXCOORD7; // 阴影坐标 #endif }; // 纹理与采样器定义 TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); TEXTURE2D(_BumpMap); SAMPLER(sampler_BumpMap); // 属性变量CBUFFER声明(URP要求) CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; half4 _BaseColor; half4 _SpecularColor; half _SpecularPower; half _SpecularScale; half4 _SecondarySpecularColor; half _SecondarySpecularPower; half _SecondarySpecularScale; half _BumpScale; half _Cutoff; CBUFFER_END // 顶点着色器:计算世界空间向量并准备光照数据 Varyings vert(Attributes input) { Varyings output = (Varyings)0; VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz); VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS, input.tangentOS); output.positionCS = vertexInput.positionCS; output.positionWS = vertexInput.positionWS; output.normalWS = normalInput.normalWS; output.tangentWS = normalInput.tangentWS; output.bitangentWS = normalInput.bitangentWS; // 关键:构建切线空间 output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap); output.viewDirWS = GetWorldSpaceNormalizeViewDir(vertexInput.positionWS); // 处理光照贴图和阴影坐标 OUTPUT_LIGHTMAP_UV(input.lightmapUV, unity_LightmapST, output.lightmapUV); OUTPUT_SH(output.normalWS.xyz, output.vertexSH); #ifdef _MAIN_LIGHT_SHADOWS output.shadowCoord = GetShadowCoord(vertexInput); #endif return output; } // Kajiya-Kay各向异性高光计算函数 half3 CalculateKajiyaKaySpecular(float3 T, float3 B, float3 N, float3 L, float3 V, half specColor, half specPower, half specScale) { // 将L和V转换到切线空间(TBN空间) float3x3 TBN = float3x3(T, B, N); float3 L_tangent = mul(TBN, L); float3 V_tangent = mul(TBN, V); // 归一化 L_tangent = normalize(L_tangent); V_tangent = normalize(V_tangent); // 计算各向异性高光的核心:基于切线和副切线方向的光线/视线分量 // 这里使用一种常见变体:计算半角向量在切线平面上的角度 float3 H = normalize(L_tangent + V_tangent); float TdotH = dot(float3(1, 0, 0), H); // 切线方向是(1,0,0) float BdotH = dot(float3(0, 1, 0), H); // 副切线方向是(0,1,0) // 使用atan2和sin来模拟沿切线方向的高光条带 float sinTH = sqrt(1.0 - TdotH * TdotH); // sin(θ) = sqrt(1 - cos²(θ)) // 另一种常用公式:float sinTH = sqrt(1.0 - TdotH * TdotH); // 这里我们直接用sinTH,它代表了垂直于切线方向的分量 float dirAtten = smoothstep(-1.0, 0.0, BdotH); // 用BdotH来做一个方向性衰减,让高光只出现在一侧 // 综合计算高光强度 float spec = dirAtten * pow(sinTH, specPower) * specScale; return specColor * spec; } // 片元着色器主函数 half4 frag(Varyings input) : SV_Target { // 采样基础纹理和法线纹理 half4 albedoAlpha = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv); half3 albedo = albedoAlpha.rgb * _BaseColor.rgb; half alpha = albedoAlpha.a * _BaseColor.a; // Alpha测试(用于裁剪渲染,如发梢) #ifdef _ALPHATEST_ON clip(alpha - _Cutoff); #endif // 采样并解码法线贴图(切线空间) half3 normalTS = UnpackNormalScale(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, input.uv), _BumpScale); // 将法线从切线空间转换到世界空间 float3x3 TBN = float3x3(input.tangentWS, input.bitangentWS, input.normalWS); half3 normalWS = normalize(mul(normalTS, TBN)); // 准备光照计算所需向量 float3 positionWS = input.positionWS; float3 normal = normalWS; float3 viewDir = normalize(input.viewDirWS); // 初始化光照颜色 half3 totalColor = albedo * 0.1; // 先给一点环境光,避免全黑 // ———— 主方向光处理 ———— Light mainLight = GetMainLight(); #ifdef _MAIN_LIGHT_SHADOWS mainLight.shadowAttenuation = MainLightRealtimeShadow(input.shadowCoord); #endif if (mainLight.distanceAttenuation > 0) { float3 lightDir = mainLight.direction; // 计算漫反射 (Lambert) half NdotL = saturate(dot(normal, lightDir)); half3 diffuse = mainLight.color * albedo * NdotL; // 计算Kajiya-Kay各向异性高光(主高光) half3 specular = CalculateKajiyaKaySpecular( input.tangentWS, input.bitangentWS, normal, lightDir, viewDir, _SpecularColor.rgb, _SpecularPower, _SpecularScale ); // 计算次级高光(通常更宽、颜色不同) half3 secondarySpecular = CalculateKajiyaKaySpecular( input.tangentWS, input.bitangentWS, normal, lightDir, viewDir, _SecondarySpecularColor.rgb, _SecondarySpecularPower, _SecondarySpecularScale ); // 合并光照贡献 totalColor += (diffuse + specular + secondarySpecular) * mainLight.color * mainLight.distanceAttenuation * mainLight.shadowAttenuation; } // ———— 附加逐像素光处理 ———— #ifdef _ADDITIONAL_LIGHTS uint pixelLightCount = GetAdditionalLightsCount(); for (uint lightIndex = 0; lightIndex < pixelLightCount; ++lightIndex) { Light additionalLight = GetAdditionalLight(lightIndex, positionWS); if (additionalLight.distanceAttenuation > 0) { float3 lightDir = additionalLight.direction; half NdotL = saturate(dot(normal, lightDir)); half3 diffuse = additionalLight.color * albedo * NdotL; half3 specular = CalculateKajiyaKaySpecular( input.tangentWS, input.bitangentWS, normal, lightDir, viewDir, _SpecularColor.rgb, _SpecularPower, _SpecularScale * 0.5 // 附加光的高光可以弱一些 ); totalColor += (diffuse + specular) * additionalLight.color * additionalLight.distanceAttenuation * additionalLight.shadowAttenuation; } } #endif // 添加环境光/光照探针/光照贴图贡献 half3 bakedGI = SampleSH(normal); totalColor += albedo * bakedGI; return half4(totalColor, alpha); } ENDHLSL } // ———————————————————————————— // Pass 1: 阴影投射通道(确保头发能投射正确的阴影) // ———————————————————————————— Pass { Name “ShadowCaster” Tags { “LightMode” = “ShadowCaster” } ZWrite On ZTest LEqual Cull Back HLSLPROGRAM #pragma vertex ShadowPassVertex #pragma fragment ShadowPassFragment #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW #pragma shader_feature _ALPHATEST_ON #include “Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl” #include “Packages/com.unity.render-pipelines.universal/Shaders/ShadowCasterPass.hlsl” ENDHLSL } } FallBack “Hidden/Universal Render Pipeline/FallbackError” CustomEditor “UnityEditor.Rendering.Universal.ShaderGUI.CustomShaderGUI” }代码关键点解析:
- 属性块(Properties):这里定义了所有可在材质面板调节的参数。特别注意
_SpecularPower(高光指数,控制高光条带的锐利度)和_SpecularScale(高光强度缩放)。次级高光参数用于模拟发丝上那层更柔和、有时带彩色的光泽。 - 顶点着色器(vert):核心任务是构建世界空间的切线空间基向量(TBN矩阵)。我们使用URP提供的
GetVertexNormalInputs函数,它帮我们正确处理了法线和切线的变换。计算出的tangentWS和bitangentWS将传递给片元着色器用于各向异性计算。 - Kajiya-Kay计算函数(CalculateKajiyaKaySpecular):这是Shader的灵魂。我们先将光线方向
L和视线方向V变换到由切线(T)、副切线(B)、法线(N)构成的切线空间中。然后计算半角向量H。高光强度主要依赖于sinTH(即H向量在垂直于切线方向上的分量大小)的幂次方。dirAtten项用于控制高光只出现在发丝的某一侧(模拟圆柱体反射特性),使高光条带更真实。 - 片元着色器(frag):
- 纹理采样:采样漫反射贴图和法线贴图。
- 法线变换:将切线空间法线贴图数据转换到世界空间,参与光照计算。
- 主光光照:使用
GetMainLight获取主光数据,计算漫反射和各向异性高光。注意我们计算了主高光和次级高光,并乘以光的衰减和阴影衰减。 - 附加光处理:循环处理每个额外的逐像素光,计算其对漫反射和高光的贡献。为了性能,通常会对附加光的高光进行削弱。
- 环境光:通过
SampleSH采样球谐函数,添加全局光照(GI)贡献。
- 阴影投射通道(ShadowCaster Pass):这个通道是必须的,它确保你的头发能在其他物体上投下阴影。URP提供了现成的
ShadowCasterPass.hlslinclude文件,我们只需要确保顶点和片元函数正确对接,并处理好Alpha裁剪即可。 - 渲染状态:在Pass中,我们通过
Blend、ZWrite、Cull控制混合、深度写入和剔除。对于完全透明的头发部分,你可能需要创建另一个使用Blend SrcAlpha OneMinusSrcAlpha、ZWrite Off的Pass,并调整Tags中的“RenderType”为“Transparent”。
4. 模型准备、材质配置与穿模解决方案
有了Shader,没有正确的模型和配置,也出不来好效果。头发渲染是一个“Shader + 模型 + 配置”三位一体的工程。
4.1 头发模型制作的核心要求
必须要有正确的切线(Tangent)信息!这是各向异性渲染的生命线。在3D建模软件(如Blender, Maya, 3ds Max)中制作头发模型时,必须确保:
- 头发的UV展开尽量保持方向一致(通常沿着发丝生长方向)。
- 在导出为FBX或直接导入Unity时,务必勾选“切线(Tangents)”选项。在Unity的模型导入设置中,检查
Normals & Tangents下的Tangents选项是否为Calculate或Import。 - 你可以通过在Shader中输出
tangentWS的颜色来检查切线是否正确。如果切线方向混乱,头发高光会变得杂乱无章。
合理的面数与拓扑:头发不需要极高的面数,但拓扑要顺着发丝走向。避免使用大块的、不规则的三角面。通常使用长条形的面片(Hair Cards)或细长的带状模型来模拟发束。面片的宽度方向应该对应副切线(Binormal)方向,长度方向对应切线(Tangent)方向。
发梢处理:为了做出自然的透明发梢,需要在漫反射贴图(
_BaseMap)的Alpha通道中绘制透明度渐变。模型上,发梢部分的面片可以适当变细或分层。
4.2 材质球配置与参数调节心得
创建材质球,使用上面写的Shader,然后开始微调。这是一门艺术:
_BaseColor:基础色。通常比你想的要暗一些,因为高光会提亮。_SpecularColor:主高光颜色。金色、白色或浅黄色比较常用,模拟头发油脂的反光。_SpecularPower:控制高光条带的“硬度”。值越大,高光线越细、越锐利。通常在80-200之间调节。直发需要更高的值(如150+),卷发可以低一些(如80-120)。_SpecularScale:控制高光强度。从0.1开始慢慢加,避免过曝。_SecondarySpecularColor和_SecondarySpecularPower/Scale:用于模拟那种扩散的、彩色的“光晕”高光。颜色可以设为淡蓝、淡紫或粉色,Power值较低(30-80),Scale值也很低(0.05-0.2),作为主高光的补充。- 法线贴图:一张好的、细微的法线贴图能极大增加发丝的细节感,模拟毛鳞片或发束间的起伏。即使是一张平铺的、轻微噪点的法线贴图也有效果。
实操心得:调节时,最好在一个HDR环境(或一个明亮的场景)中,使用一个强方向光(如平行光)从侧上方照射。旋转视角和灯光方向,观察高光条带是否始终沿着发丝方向移动。这是判断各向异性是否生效的最直观方法。
4.3 彻底解决穿模:渲染队列、深度与双通道技巧
穿模的本质是深度测试(ZTest)和渲染顺序(Render Queue)的问题。Unity默认按照材质渲染队列值从小到大渲染,对于同一队列,顺序可能不确定。对于头发这种既有不透明部分(发根)又有半透明部分(发梢)的物体,处理不当就会和身体或其他头发穿模。
方案一:拆分渲染队列(基础且有效)这是最简单的方法。为头发的不透明部分(如发根、厚实的发束)和半透明部分(如发梢、飞散的细发)分别制作两个材质球,使用同一个Shader但不同的配置。
- 不透明材质:渲染队列设为
“Geometry”(例如2000)。ZWrite On,ZTest LEqual,Blend Off。 - 半透明材质:渲染队列设为
“Transparent”(例如3000)。ZWrite Off,ZTest LEqual,Blend SrcAlpha OneMinusSrcAlpha。 在模型上,将不同部分分配到不同的子网格(SubMesh)并应用对应的材质。确保身体模型的渲染队列在不透明头发之后、半透明头发之前(例如身体用2500)。这样渲染顺序就是:不透明头发 -> 身体 -> 半透明头发,能解决大部分穿模。
方案二:双通道渲染(Two-Pass,效果更好)在同一个Shader/材质中实现。这需要两个Pass。
- 第一个Pass(Depth Prepass):只写入深度,不输出颜色。设置
ColorMask 0(不写入颜色),ZWrite On,使用一个简化的顶点着色器。这个Pass会先把头发的深度信息写入深度缓冲区。 - 第二个Pass(Forward Lit):正常渲染颜色,但设置
ZWrite Off,ZTest Equal(或LEqual)。这样它只会渲染与第一遍写入的深度值匹配(或更近)的像素。 这样做的优点是,即使头发自身是半透明的,它也能正确遮挡后面的物体(如身体),因为深度信息在第一遍已经写入了。同时,ZWrite Off避免了半透明部分自身的深度互相覆盖导致的排序问题。这个方案对Shader复杂度有要求,且会增加一个Draw Call。
方案三:使用Alpha To Mask(适用于发梢硬边)如果发梢是类似Alpha Test的硬边缘,可以启用AlphaToMask On。这会将Alpha测试转化为多重采样抗锯齿(MSAA)的覆盖掩码,能在边缘产生更平滑的过渡,同时深度处理比真正的Alpha Blend更简单。在Properties中加一个[Toggle(_ALPHATEST_ON)] _AlphaClip开关,在SubShader中根据关键字设置AlphaToMask状态。这适合风格化渲染。
在我的项目中,方案一(拆分材质)在大多数情况下已经足够,且性能最优。方案二(双通道)用于对透明质感要求极高的写实发型。你可以根据项目需求选择。
5. 性能优化与常见问题排查
5.1 性能优化要点
实时渲染头发,尤其是复杂的各向异性高光,对性能是个考验。以下是一些优化方向:
减少计算量:
- 将向量归一化(normalize)移到顶点着色器:在
vert函数中计算并归一化tangentWS、bitangentWS、viewDirWS,然后传递给片元着色器。虽然会因插值产生微小误差,但性能提升显著。 - 简化次级高光:如果性能吃紧,可以去掉次级高光的计算,或者将其强度(
_SecondarySpecularScale)设为零。 - 限制附加光的影响:在片元着色器的附加光循环中,可以只计算漫反射,忽略高光,或者用一个更简单的各向同性高光代替Kajiya-Kay计算。
- 将向量归一化(normalize)移到顶点着色器:在
合理使用Shader变体:
#pragma multi_compile指令会生成大量变体。如果项目确定不用光照贴图、屏幕空间遮蔽(SSO)等,可以移除对应的指令,减少包体和内存占用。模型与Draw Call优化:
- 使用发片(Hair Cards)技术,用少量面片模拟一大片头发,这是游戏行业的标准做法。
- 合并静态头发网格,减少Draw Call。
- 使用LOD(Level of Detail),在远处使用面数更少、Shader更简化的头发模型。
后处理影响:抗锯齿(如TAA)可能会使头发的高光条带闪烁或抖动。可以尝试调整抗锯齿设置,或在Shader中对高光计算做一些轻微的随机化(噪声)来掩盖。
5.2 常见问题与排查表
| 问题现象 | 可能原因 | 排查与解决方案 |
|---|---|---|
| 高光不出现或方向错误 | 1. 模型切线数据错误或丢失。 2. 切线(T)和副切线(B)在Shader中计算错误。 3. 光线或视线向量未归一化。 | 1. 在建模软件和Unity导入设置中确认切线已正确生成/导入。在Shader中输出tangentWS作为颜色查看是否连续、方向一致。2. 检查顶点着色器中 bitangentWS的计算(cross(normalWS, tangentWS) * tangentOS.w)。3. 确保在 CalculateKajiyaKaySpecular函数内对L_tangent和V_tangent进行了归一化。 |
| 头发全黑或颜色异常 | 1. 光照信息获取失败。 2. 漫反射贴图采样或 _BaseColor设置问题。3. Shader中光照计算被意外乘以0。 | 1. 检查场景中是否有有效的方向光。在Shader中直接返回mainLight.color或NdotL,看是否能得到正确颜色。2. 检查UV是否正确,尝试返回纯色 albedo。3. 逐步检查光照贡献累加过程,避免某个系数为0。 |
| 半透明部分排序错乱,严重穿模 | 1. 渲染队列设置错误。 2. 半透明部分未关闭深度写入(ZWrite Off)。 3. 多个半透明物体交错,Unity排序能力有限。 | 1. 确保半透明材质队列高于不透明物体(身体)。 2. 在半透明Pass中明确设置 ZWrite Off。3. 考虑使用双通道渲染或Alpha To Mask方案。尽量拆分模型,减少单个网格内半透明与不透明区域的交错。 |
| 高光边缘有锯齿(Aliasing) | 高光计算(pow(sinTH, specPower))在边缘产生高频信号,抗锯齿不足。 | 1. 开启MSAA或使用后处理抗锯齿(SMAA, TAA)。 2. 在计算 sinTH后,使用smoothstep或给specPower一个很小的随机偏移(基于屏幕坐标的噪声)来柔化边缘。 |
| 在移动设备上帧率下降明显 | 1. 片元着色器计算过于复杂。 2. 逐像素光数量过多。 3. 过度绘制(Overdraw)严重,特别是半透明头发。 | 1. 实施上述“减少计算量”的优化。 2. 严格限制影响头发的附加光数量,或使用逐顶点光照版本( _ADDITIONAL_LIGHTS_VERTEX)。3. 优化头发模型的面数和重叠程度,使用更简单的LOD。 |
调试Shader时,一个非常有效的方法是使用Frame Debugger和Shader变体查看器。Frame Debugger可以让你看清每一个Draw Call的状态和结果,确认渲染队列和混合状态。在Shader代码中临时返回某个中间计算值(如float4(tangentWS.xyz * 0.5 + 0.5, 1.0))作为颜色输出,是可视化诊断向量问题的黄金手段。
最后,头发渲染没有唯一的标准答案。Kajiya-Kay模型是一个强大的起点,但现代3A游戏往往结合了更多技术,如基于物理的Marschner模型、发丝级别的光线追踪(如NVIDIA HairWorks)或基于发束的体积渲染。但对于绝大多数Unity URP项目,一个精心调校的Kajiya-Kay Shader,配合正确的模型和渲染设置,已经足以让你的角色发型摆脱穿模的尴尬,焕发出真实而动人的光彩。关键在于理解原理,耐心调试,并根据你的项目风格(写实、卡通、风格化)去调整那些色彩与光泽的参数。