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 { "RenderType"="Opaque" } 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-else
5.2 调试与查错方法
Shader调试一直是个难题,但掌握一些方法可以大幅提升效率。
使用颜色输出进行可视化调试:这是最古老也最有效的方法。当你怀疑自定义函数的某个中间结果不对时,直接将其作为最终颜色输出到屏幕。
// 在片元着色器中,临时将自定义函数的返回值输出为颜色 fixed4 frag (v2f i) : SV_Target { float someValue = MyBuggyFunction(i.uv); // 直接返回该值,观察屏幕上的颜色分布 return float4(someValue.xxx, 1.0); }通过观察颜色(白色代表1,黑色代表0,灰色代表中间值),你可以快速判断函数返回值是否在预期范围内,分布是否合理。
利用Frame Debugger和RenderDoc:Unity的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开发的世界,从此变得井井有条,充满乐趣。