Unity URP中Kajiya-Kay头发渲染Shader实现与穿模解决

📅 2026/8/9 3:53:55
Unity URP中Kajiya-Kay头发渲染Shader实现与穿模解决
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 ShaderUnity帮你处理很多光照和阴影的宏。但在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-80Scale值也很低0.05-0.2作为主高光的补充。法线贴图一张好的、细微的法线贴图能极大增加发丝的细节感模拟毛鳞片或发束间的起伏。即使是一张平铺的、轻微噪点的法线贴图也有效果。实操心得调节时最好在一个HDR环境或一个明亮的场景中使用一个强方向光如平行光从侧上方照射。旋转视角和灯光方向观察高光条带是否始终沿着发丝方向移动。这是判断各向异性是否生效的最直观方法。4.3 彻底解决穿模渲染队列、深度与双通道技巧穿模的本质是深度测试ZTest和渲染顺序Render Queue的问题。Unity默认按照材质渲染队列值从小到大渲染对于同一队列顺序可能不确定。对于头发这种既有不透明部分发根又有半透明部分发梢的物体处理不当就会和身体或其他头发穿模。方案一拆分渲染队列基础且有效这是最简单的方法。为头发的不透明部分如发根、厚实的发束和半透明部分如发梢、飞散的细发分别制作两个材质球使用同一个Shader但不同的配置。不透明材质渲染队列设为“Geometry”例如2000。ZWrite On,ZTest LEqualBlend Off。半透明材质渲染队列设为“Transparent”例如3000。ZWrite Off,ZTest LEqualBlend SrcAlpha OneMinusSrcAlpha。 在模型上将不同部分分配到不同的子网格SubMesh并应用对应的材质。确保身体模型的渲染队列在不透明头发之后、半透明头发之前例如身体用2500。这样渲染顺序就是不透明头发 - 身体 - 半透明头发能解决大部分穿模。方案二双通道渲染Two-Pass效果更好在同一个Shader/材质中实现。这需要两个Pass。第一个PassDepth Prepass只写入深度不输出颜色。设置ColorMask 0不写入颜色ZWrite On使用一个简化的顶点着色器。这个Pass会先把头发的深度信息写入深度缓冲区。第二个PassForward Lit正常渲染颜色但设置ZWrite OffZTest 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计算。合理使用Shader变体#pragma multi_compile指令会生成大量变体。如果项目确定不用光照贴图、屏幕空间遮蔽SSO等可以移除对应的指令减少包体和内存占用。模型与Draw Call优化使用发片Hair Cards技术用少量面片模拟一大片头发这是游戏行业的标准做法。合并静态头发网格减少Draw Call。使用LODLevel 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配合正确的模型和渲染设置已经足以让你的角色发型摆脱穿模的尴尬焕发出真实而动人的光彩。关键在于理解原理耐心调试并根据你的项目风格写实、卡通、风格化去调整那些色彩与光泽的参数。