
1. 从“复制粘贴”到“封装复用”为什么我们需要Shader自定义函数在Unity Shader开发的初期我们大多数人都是从“复制粘贴”开始的。网上找到一个实现边缘光效果的代码块复制到自己的Surface Shader里看到一个不错的高光计算又粘贴到顶点-片元着色器中。很快.shader文件就变成了一个臃肿、难以阅读和维护的“意大利面条”代码。更头疼的是当你需要在另一个Shader里实现同样的边缘光效果时你又得回去翻找那个古老的工程小心翼翼地复制然后祈祷它在新环境下能正常工作。这种开发模式不仅效率低下更是Bug的温床。自定义函数Custom Function就是解决这一系列痛点的核心工具。它本质上是一种代码封装和复用的手段允许你将一段实现特定功能的Shader代码例如计算菲涅尔效应、进行噪声采样、转换坐标空间定义为一个独立的函数单元。之后你可以在Shader的任何地方像调用内置函数如dot,normalize,lerp一样调用它。这带来的好处是革命性的逻辑清晰、维护方便、复用高效。你的Shader代码从一个混乱的脚本变成了一个由清晰模块搭建的结构化工程。从网络热词如“unity shadergraph教程包”、“unity宏定义”、“unity特性”可以看出社区对Shader的可视化开发、代码组织和高级特性有着持续的关注。而“ysa toon animetoon shader v1.0”、“scroll, paper map roll shader”这类具体的Shader资源其内部实现必然大量依赖自定义函数来构建复杂的视觉效果。理解并掌握自定义函数是你从Shader使用者迈向Shader创造者的关键一步。本文将彻底拆解Unity Shader中自定义函数的方方面面。我们不仅会学习如何在Surface Shader和顶点-片元Shader中声明与使用它们更会深入探讨如何利用CGINCLUDE块实现跨Shader的全局复用如何与#define宏定义配合提升性能以及如何为Shader Graph编写可在子图中复用的自定义函数节点。最后我会分享几个实战中提炼出的高级技巧和避坑指南这些是你在官方文档里很难找到的“硬核”经验。2. 两种核心着色器模型下的函数定义与实践Unity支持多种着色器编写方式但最核心、最灵活的两大流派是Surface Shader表面着色器和Vertex Fragment Shader顶点-片元着色器。自定义函数在这两种模型下的编写位置和语法细节有显著不同理解这些差异是正确使用的第一步。2.1 Surface Shader中的函数在CGPROGRAM内封装光照细节Surface Shader是Unity提供的一种高级抽象它帮你处理了多光源光照的复杂交互你只需要关心表面属性Albedo, Normal, Emission等的计算。自定义函数在这里主要用来封装表面属性准备阶段的复杂计算。一个典型的Surface Shader结构如下Shader Custom/MySurfaceShader { Properties { ... } SubShader { Tags { RenderTypeOpaque } CGPROGRAM #pragma surface surf Standard fullforwardshadows // --- 自定义函数通常定义在这里在surf函数之上 --- float3 MyCustomColorModification(float3 originalColor, float modifier) { // 实现一些颜色变换逻辑例如去饱和度、色调偏移 float luminance dot(originalColor, float3(0.299, 0.587, 0.114)); return lerp(originalColor, luminance.xxx, modifier); } struct Input { ... }; void surf (Input IN, inout SurfaceOutputStandard o) { // 在surf函数中调用自定义函数 float3 baseColor _Color.rgb; float3 modifiedColor MyCustomColorModification(baseColor, _Saturation); o.Albedo modifiedColor; // ... 其他属性赋值 } ENDCG } FallBack Diffuse }关键点与经验定义位置自定义函数必须写在CGPROGRAM和ENDCG块内部并且强烈建议定义在surf函数之前。这是因为HLSL/Cg语言Unity Shader使用的语言通常需要先声明或定义函数然后才能调用。把函数定义放在调用它的surf函数后面会导致编译错误。作用域在此处定义的函数其作用域仅限于当前CGPROGRAM块内。同一个SubShader内的另一个Pass或者另一个SubShader都无法直接访问它。典型用途在Surface Shader中自定义函数非常适合用来计算复杂的纹理混合、基于世界坐标或屏幕坐标的特效如雪地覆盖、水面涟漪、以及自定义的细节叠加逻辑。它能让你的surf函数保持简洁专注于数据的输入输出。2.2 Vertex Fragment Shader中的函数贯穿渲染管线的计算单元顶点-片元着色器提供了更底层的控制你需要手动编写顶点着色器vert和片元着色器frag函数。自定义函数在这里的应用场景更广泛可以在顶点阶段进行坐标变换也可以在片元阶段进行复杂的光照和颜色计算甚至可以在两个阶段共享工具函数。一个典型的顶点-片元着色器结构如下Shader Custom/MyVFShader { Properties { ... } SubShader { Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc // --- 可被vert和frag共用的工具函数 --- float Remap(float value, float inMin, float inMax, float outMin, float outMax) { return outMin (value - inMin) * (outMax - outMin) / (inMax - inMin); } // --- 顶点着色器输入/输出结构体 --- struct appdata { ... }; struct v2f { ... }; // --- 顶点着色器 --- v2f vert (appdata v) { v2f o; // 在顶点着色器中调用自定义函数 float offsetY Remap(v.vertex.y, _MinHeight, _MaxHeight, 0.0, _MaxOffset); v.vertex.y offsetY; o.vertex UnityObjectToClipPos(v.vertex); // ... 其他数据传递 return o; } // --- 片元着色器 --- fixed4 frag (v2f i) : SV_Target { // 在片元着色器中同样可以调用同一个函数 float fogFactor Remap(i.worldPos.y, _FogStartHeight, _FogEndHeight, 0.0, 1.0); // ... 颜色混合计算 return finalColor; } ENDCG } } }关键点与经验定义位置同样定义在CGPROGRAM块内vert和frag函数之前。这样两个主函数都能调用它。跨阶段复用这是顶点-片元着色器中使用自定义函数的一大优势。像上面例子中的Remap重映射函数它是一个纯工具函数不依赖特定的渲染阶段既可以在vert中处理顶点动画也可以在frag中计算雾效或边缘过渡极大提升了代码的复用性。性能考量在片元着色器中调用的函数会执行很多次每个像素一次因此要尽量避免在其中进行非常复杂的循环或分支判断。如果某段计算在顶点间变化平滑可以考虑将其移到顶点着色器中计算然后通过v2f结构体插值后传给片元着色器这是一种常见的优化手段例如计算模型空间下的法线方向用于简单的环境光。3. 高级复用策略CGINCLUDE与宏定义的威力当你拥有一个非常实用、希望在多个不同Shader中使用的函数时比如一个精心调试的HSV转RGB函数或者一个模拟纸张褶皱的噪声函数在每个Shader文件中重复定义它显然是不可接受的。这时你需要更强大的代码复用机制。3.1 使用CGINCLUDE实现全局函数库CGINCLUDE是Unity Shader中的一个特殊代码块。放在这个块中的代码对于该Shader文件内的所有SubShader和所有Pass都是可见的。这使它成为在单个Shader文件内组织共享代码的完美工具。更强大的是你可以将CGINCLUDE块的内容提取到一个独立的.cginc文件中然后在任何Shader中通过#include YourFunctionLibrary.cginc来引用它实现真正的跨Shader全局复用。创建全局函数库文件在你的项目Assets文件夹下通常放在Shaders或Includes子文件夹中更规范创建一个文本文件将其后缀名改为.cginc例如MyShaderHelpers.cginc。在这个.cginc文件中编写你的通用函数。// File: Assets/Shaders/Includes/MyShaderHelpers.cginc #ifndef MY_SHADER_HELPERS_INCLUDED #define MY_SHADER_HELPERS_INCLUDED // 1. HSV转RGB函数 (非常实用的颜色空间转换) float3 HSVtoRGB(float3 hsv) { float4 K float4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); float3 p abs(frac(hsv.xxx K.xyz) * 6.0 - K.www); return hsv.z * lerp(K.xxx, clamp(p - K.xxx, 0.0, 1.0), hsv.y); } // 2. 简单的三角波噪声 (用于低成本动画或扰动) float TriangleWave(float t, float frequency, float amplitude) { return abs(frac(t * frequency) * 2.0 - 1.0) * amplitude; } // 3. 根据深度和法线计算边缘强度 (用于轮廓描边等效果) float GetEdgeFactor(float depth, float3 normal, float sensitivity) { float depthGradient fwidth(depth); float normalEdge length(fwidth(normal)) * sensitivity; return saturate((depthGradient normalEdge) * _EdgeScale); } #endif // MY_SHADER_HELPERS_INCLUDED注意#ifndef如果没有定义和#define以及#endif的使用。这是标准的头文件保护机制防止同一个文件在单个Shader中被多次包含而导致重复定义错误。在Shader中引用Shader Custom/UsingInclude { Properties { ... } SubShader { Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag // 引入自定义函数库 #include Assets/Shaders/Includes/MyShaderHelpers.cginc // ... 之后就可以直接使用HSVtoRGB, TriangleWave等函数了 fixed4 frag (v2f i) : SV_Target { float3 hsvColor float3(_Hue, _Saturation, _Value); float3 rgbColor HSVtoRGB(hsvColor); // 直接调用 // ... } ENDCG } } }注意.cginc文件的路径是相对于项目的Assets文件夹的。为了更好的可移植性Unity也支持使用相对路径例如如果你的Shader文件在Assets/Shaders下.cginc在Assets/Shaders/Includes下可以用#include Includes/MyShaderHelpers.cginc。3.2 函数与宏定义 (#define) 的协同与抉择除了函数#define宏定义也是Shader中常用的代码封装方式。它们有什么区别又该如何选择自定义函数是一个真正的函数有输入参数、返回值和局部作用域。它会被编译成一系列指令调用时会产生函数调用的开销虽然现代GPU上开销很小且在Shader编译时常被内联优化。优点是逻辑清晰可读性强易于调试某些工具可以单步进入函数。宏定义本质是文本替换。在编译前预处理器会把所有使用宏名的地方替换成定义的代码块。它没有作用域概念也没有调用开销因为代码被直接展开。缺点是容易因为文本替换产生意想不到的错误特别是参数被多次计算时且难以调试。示例对比// 方式一使用自定义函数 float CalculateFresnel(float3 viewDir, float3 normal, float power) { float fresnel 1.0 - saturate(dot(viewDir, normal)); return pow(fresnel, power); } // 调用float f CalculateFresnel(vDir, norm, _FresnelPower); // 方式二使用宏定义 #define CALCULATE_FRESNEL(v, n, p) pow(1.0 - saturate(dot(v, n)), p) // 调用float f CALCULATE_FRESNEL(vDir, norm, _FresnelPower); // 预处理器处理后这行会变成float f pow(1.0 - saturate(dot(vDir, norm)), _FresnelPower);选择策略使用函数当代码逻辑较复杂、有多行代码、或者你希望代码更清晰易维护时。对于工具类函数如数学变换、颜色空间转换也推荐使用函数。使用宏当代码非常简单通常是一行表达式且追求极致性能避免任何可能的函数调用开销尽管编译器常会内联或者你需要“生成”代码例如根据不同的材质属性开关生成不同的计算分支时。在编写Surface Shader的#pragma surface指令中那些自定义的lighting模型时宏定义非常常见。一个实用的经验法则是默认使用自定义函数以提高代码质量在明确诊断出性能瓶颈且该计算在片元着色器中被海量调用时再考虑将其改为宏定义进行优化。4. 为Shader Graph赋能创建可复用的自定义节点对于大量使用可视化编程工具Shader Graph的开发者来说自定义函数同样不可或缺。Shader Graph允许你创建“Sub Graph”子图或“Custom Function Node”自定义函数节点将复杂的节点网络封装成一个可复用的功能模块。4.1 创建Sub Graph子图这是最直观、最图形化的方式。你可以像创建主Shader Graph一样创建一个Sub Graph资产.shadersubgraph。在其中你有独立的输入端口In和输出端口Out内部可以使用任何节点搭建逻辑。步骤在Project窗口右键 - Create - Shader - Sub Graph。在Sub Graph编辑器中定义输入属性例如Vector3 Normal,Float Intensity和输出属性例如Float Fresnel。使用节点网络连接计算逻辑最终连接到输出节点。保存后在主Shader Graph中你可以像添加普通节点一样在节点菜单中找到并添加这个Sub Graph节点。优点完全可视化逻辑清晰可以封装极其复杂的节点网络。缺点生成的底层HLSL代码可能不如手写的函数高效且调试相对困难。4.2 创建Custom Function Node自定义函数节点这是更接近手写Shader代码的方式。你直接在节点中嵌入HLSL代码块适合实现那些用标准节点难以实现或实现起来非常繁琐的数学运算或算法。步骤在主Shader Graph中右键 - Create Node - Search -Custom Function。选中该节点在Graph Inspector面板中你可以为其定义输入、输出端口。最关键的一步是在Type下拉菜单中选择String然后在下方的大文本框中直接写入你的HLSL函数代码。示例实现一个简单的噪声函数节点输入UV (Vector2),Scale (Float)输出Noise (Float)函数体// 一个简单的伪随机函数 float rand(float2 co) { return frac(sin(dot(co.xy, float2(12.9898, 78.233))) * 43758.5453); } // 主函数名称必须与“Name”字段一致例如这里如果Name是“SimpleNoise” void SimpleNoise_float(float2 UV, float Scale, out float Noise) { Noise rand(UV * Scale); }注意Shader Graph自定义函数有一个特殊要求。如果你的主图使用Precision为Float函数名后缀必须是_float如果使用Half则后缀是_half。为了兼容通常同时定义两个版本或者使用#ifdef进行判断。这是Shader Graph将节点连接到生成代码的约定。实战心得调试技巧在Custom Function Node中调试非常困难。一个有效的方法是先将你的函数代码在一个普通的.shader文件中测试通过确保逻辑和语法无误再复制到Shader Graph中。性能注意写在Custom Function Node中的代码会直接嵌入到生成的Shader中。要避免在其中编写循环次数不确定或过于复杂的算法因为它会在每个像素或顶点执行。端口匹配函数的输入参数和输出参数必须与你在Graph Inspector中定义的端口完全匹配包括类型和顺序否则会导致编译错误或连接错误。5. 实战进阶性能优化、调试与常见陷阱掌握了基本语法和复用方法后要写出真正高效、健壮的自定义函数还需要了解一些进阶知识和避坑指南。5.1 性能优化精要Shader的性能尤其是片元着色器的性能直接关系到帧率。在编写自定义函数时时刻要有性能意识。精度选择 (halfvsfloat)在移动平台或对性能敏感的场景合理使用half半精度浮点数至关重要。对于颜色值范围0-1、UV坐标、简单的中间计算结果使用half通常足够且更快。但对于世界坐标、法线、深度值、复杂的数学运算如pow,sin建议使用float以保证精度。你可以在函数参数和返回值中明确指定精度。// 好的实践对颜色操作使用half half3 TintColor(half3 original, half3 tint) { return original * tint; } // 对位置计算使用float float CalculateDistance(float3 posA, float3 posB) { return distance(posA, posB); }避免冗余计算如果一段计算在着色器程序中被多次以相同的参数调用考虑将其结果存储在临时变量中或者在顶点-片元着色器中在顶点着色器计算后通过插值传给片元着色器。自定义函数本身并不能自动优化重复计算需要开发者有意识地组织代码。// 低效在片元着色器中多次计算相同的视图方向 float spec1 CalculateSpecular(viewDir, normal, lightDir1); float spec2 CalculateSpecular(viewDir, normal, lightDir2); // 高效计算一次并复用 float3 vDir normalize(_WorldSpaceCameraPos - i.worldPos); float spec1 CalculateSpecular(vDir, normal, lightDir1); float spec2 CalculateSpecular(vDir, normal, lightDir2);慎用分支和循环GPU是并行处理器分支if-else和循环for,while会严重破坏并行性导致性能下降。在自定义函数中尽量使用step(),lerp(),saturate()等内置函数来替代简单的条件判断。如果必须使用循环确保循环次数是常数并且尽可能少。// 尽量避免 if (dot(normal, lightDir) 0) { diffuse dot(normal, lightDir); } else { diffuse 0; } // 推荐使用 diffuse max(0, dot(normal, lightDir)); // 效果等同于上面的if-else5.2 调试与查错方法Shader调试一直是个难题但掌握一些方法可以大幅提升效率。使用颜色输出进行可视化调试这是最古老也最有效的方法。当你怀疑自定义函数的某个中间结果不对时直接将其作为最终颜色输出到屏幕。// 在片元着色器中临时将自定义函数的返回值输出为颜色 fixed4 frag (v2f i) : SV_Target { float someValue MyBuggyFunction(i.uv); // 直接返回该值观察屏幕上的颜色分布 return float4(someValue.xxx, 1.0); }通过观察颜色白色代表1黑色代表0灰色代表中间值你可以快速判断函数返回值是否在预期范围内分布是否合理。利用Frame Debugger和RenderDocUnity的Frame Debugger可以查看每个Draw Call的渲染状态和结果。结合上一步的颜色输出你可以定位到是哪个Shader、哪个Pass出了问题。更强大的工具如RenderDoc可以捕获一帧完整的GPU调用并允许你单步调试HLSL代码需要Shader支持调试编译且过程较复杂是解决深层次Shader Bug的终极武器。仔细阅读控制台错误信息Unity Shader编译器给出的错误信息通常很直接会指明出错的文件、行号和大致原因。例如“undeclared identifier ‘xxx’”意味着变量或函数未定义“type mismatch”意味着类型不匹配。养成编译Shader后第一时间查看控制台的习惯。5.3 必须绕开的常见陷阱函数重载的陷阱HLSL/Cg不支持像C#那样的函数重载即同名函数通过参数区分。如果你定义了两个同名的函数即使参数不同也会导致编译错误。务必确保函数名全局唯一或者通过不同的文件名对于.cginc或命名空间一种通过宏模拟命名空间的方法来管理。默认参数值HLSL/Cg不支持默认参数值。下面的写法是错误的// 错误HLSL不支持默认参数 float MyFunc(float a, float b 1.0) { ... }如果需要类似功能只能通过定义多个不同参数数量的函数来模拟但这很繁琐。通常的解决方案是在调用处显式传入所有参数或者将相关配置参数定义为Shader的Properties或全局变量。递归调用Shader语言严格禁止递归函数调用因为GPU的执行模型无法处理不确定的调用深度。任何形式的递归都会导致编译错误。.cginc文件的循环包含如果A.cginc包含了B.cginc而B.cginc又包含了A.cginc就会形成循环包含导致编译失败。良好的文件组织和头文件保护#ifndef可以避免这个问题但更重要的是在设计函数库时要有清晰的层次结构。Shader Graph自定义函数的精度后缀如前所述这是Shader Graph特有的坑。忘记添加_float或_half后缀或者后缀与主图精度不匹配是导致Custom Function Node无法工作的最常见原因。务必仔细检查函数签名。掌握自定义函数是解锁Unity Shader高级玩法的钥匙。它将你从重复和混乱中解放出来让你能够像搭积木一样构建复杂而优雅的视觉特效。从今天起尝试将你Shader中那些重复的代码片段提取出来封装成一个个清晰、可测试、可复用的函数模块。你会发现Shader开发的世界从此变得井井有条充满乐趣。