Unity URP Shader迁移指南:从CG到HLSL的完整实战解析
1. 项目概述:为什么我们要从CG转向HLSL?
如果你是一位在Unity里写过几年Shader的开发者,大概率对CG语言(C for Graphics)不会陌生。在Unity的Built-in渲染管线时代,CG是ShaderLab中编写着色器代码的默认语言,它语法简洁,与DirectX的HLSL(High-Level Shader Language)高度相似,很多教程和祖传代码都基于此。然而,随着Unity引入可编程渲染管线(SRP),特别是Universal Render Pipeline(URP)成为新的主流,一个根本性的变化发生了:URP不再支持CG语言,强制要求使用HLSL。
这不仅仅是换个关键字那么简单。很多开发者,包括我自己,在第一次尝试将老项目升级到URP,或者将网上找到的酷炫CG Shader移植过来时,都踩过不少坑。编译器报错信息可能让人一头雾水,一些在CG里运行良好的语法在HLSL里直接失效,甚至整个光照模型的表现都可能天差地别。这个迁移过程,本质上是从一个旧的、被Unity高度封装和扩展的着色器环境,转向一个更标准、更贴近底层图形API的现代着色器环境。
所以,这篇内容的目的非常直接:手把手带你走一遍从CG到HLSL的迁移之路。我不会只给你一个干巴巴的语法对照表,而是会结合一个完整的、有代表性的Shader案例,从结构、语法、内置变量、函数等多个维度进行逐行对比和解析。你会看到,迁移不仅仅是“把CGPROGRAM改成HLSLPROGRAM”,更重要的是理解背后的渲染管线逻辑变化,以及如何用HLSL的方式正确地表达光照、采样和坐标变换。无论你是想升级遗留资产,还是想深入学习现代URP Shader的编写方式,这篇内容都将提供一份详实的“避坑指南”和实操手册。
2. 核心思路拆解:理解渲染管线的范式转移
在动手改代码之前,我们必须先理解为什么会有这个迁移要求。这背后是Unity渲染架构的一次重大升级。
2.1 Built-in与URP的核心差异
在Built-in管线中,Unity提供了一套固定的、黑盒的渲染流程。你写的CG Shader,实际上是被Unity的底层引擎代码所包裹和驱动的。很多功能,比如前向渲染中的多个逐像素光源处理、阴影接收、雾效等,都是由Unity在背后帮你处理好了,你只需要在Surface Shader里写几句#pragma surface surf Lambert,或者在一些Pass里使用固定的光照模式(如ForwardBase)就能获得不错的效果。这种方式的优点是上手快,但缺点是定制性差,性能优化空间小,且不同项目间的渲染效果难以保证一致。
URP则完全不同。它属于可编程渲染管线(SRP),意味着渲染的整个流程——从物体排序、光照计算到后处理——都暴露给开发者,并可以通过C#脚本进行配置和扩展。为了确保这套管线的高效和可预测性,URP使用了一套全新的、标准化的Shader库和着色器代码编写规范。HLSL作为DirectX的标准着色器语言,自然成为了URP的首选和强制要求。URP提供了一系列内置的HLSL包含文件(如Core.hlsl,Lighting.hlsl),定义了标准的函数、常量和数据结构,确保所有Shader都能在同一个渲染上下文中正确工作。
2.2 迁移工作的两个层面
因此,我们的迁移工作可以分为两个层面:
- 语法层面:将CG的关键字、数据类型、内置变量和函数,替换为HLSL的等效物或URP提供的对应实现。
- 架构层面:理解并适配URP的渲染流程。例如,Built-in中你可能在一个Pass里处理多个光源,而在URP中,通常使用单Pass前向渲染(Single Pass Forward)配合
Lighting.hlsl中的函数来迭代计算每个光源的贡献。阴影、雾效等也不再是自动的,需要你显式地包含相应的HLSL文件并调用函数。
注意:很多人误以为HLSL和CG完全一样,只是名字不同。实际上,Unity中的CG是NVIDIA对HLSL的一个扩展实现,并加入了一些Unity特有的语法糖和宏。在URP中,这些“糖”大部分被剥离了,我们需要回归到更标准的HLSL,并通过URP的库来获得高级功能。
3. 实战迁移:一个标准Surface Shader的蜕变
理论说再多不如看代码。我们以一个经典的、基于Built-in管线的Diffuse + Specular(漫反射+高光)Surface Shader作为迁移对象。我会先给出CG版本的核心代码,然后一步步将其转化为能在URP中运行的HLSL版本,并解释每一处修改的原因。
3.1 CG原版Shader代码剖析
首先,我们看看这个Shader在Built-in管线中长什么样。为了聚焦核心,我省略了一些不关键的属性定义和Fallback。
// Built-in CG Shader Shader "Custom/LegacyDiffuseSpecular" { Properties { _MainTex ("Albedo (RGB)", 2D) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Metallic ("Metallic", Range(0,1)) = 0.0 _SpecColor ("Specular Color", Color) = (1,1,1,1) } SubShader { Tags { "RenderType"="Opaque" } LOD 200 CGPROGRAM // 关键:使用Surface Shader,并指明光照模型 #pragma surface surf BlinnPhong fullforwardshadows #pragma target 3.0 sampler2D _MainTex; half _Glossiness; half _Metallic; fixed4 _SpecColor; struct Input { float2 uv_MainTex; }; void surf (Input IN, inout SurfaceOutput o) { // 采样主纹理作为反照率 fixed4 c = tex2D (_MainTex, IN.uv_MainTex); o.Albedo = c.rgb; // 将平滑度传递给高光强度 o.Specular = _Glossiness; // 使用_SpecColor作为高光颜色 o.Gloss = _SpecColor.rgb; o.Alpha = c.a; } ENDCG } FallBack "Diffuse" }这段代码非常典型:
#pragma surface surf BlinnPhong:声明这是一个Surface Shader,使用surf函数处理表面参数,光照模型为BlinnPhong。fullforwardshadows指令用于支持完整的阴影。SurfaceOutput结构体:这是Built-in管线定义的标准输出结构,包含Albedo,Specular,Gloss等成员。tex2D:CG/HLSL中标准的2D纹理采样函数。- 光照计算完全由引擎黑盒完成,
surf函数只负责提供表面参数。
3.2 HLSL/URP版本迁移详解
现在,我们将它迁移到URP。在URP中,我们通常不再使用Surface Shader这种高级抽象,而是直接编写顶点/片元着色器(Vertex/Fragment Shader),以便更精细地控制渲染流程。以下是迁移后的完整HLSL代码,我会在关键位置插入详细注释。
// URP HLSL Shader Shader "Custom/URPDiffuseSpecular" { Properties { // 属性块基本保持不变,这是ShaderLab的语法,与管线无关 _MainTex ("Albedo (RGB)", 2D) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Metallic ("Metallic", Range(0,1)) = 0.0 _SpecColor ("Specular Color", Color) = (1,1,1,1) } SubShader { // URP使用特定的RenderType和Queue标签,并需要指定渲染管线 Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } LOD 300 // URP通常使用更高的LOD基准 Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForward" } // 关键:指明此Pass用于URP前向渲染 HLSLPROGRAM // 关键:使用HLSLPROGRAM块替代CGPROGRAM // 核心:包含URP的核心HLSL库文件 #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" // 定义顶点着色器和片元着色器入口函数 #pragma vertex vert #pragma fragment frag // 确保在GLES2平台上有足够的精度 #pragma prefer_hlslcc gles #pragma exclude_renderers d3d11_9x #pragma target 3.0 // 纹理和采样器定义方式变化 // URP推荐使用TEXTURE2D宏和SAMPLER宏,以支持不同平台的优化 TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); // 属性变量需要通过CBUFFER声明,以支持SRP Batcher优化 CBUFFER_START(UnityPerMaterial) float4 _MainTex_ST; // 纹理的缩放和偏移,命名必须为`_TextureName_ST` half _Glossiness; half _Metallic; half4 _SpecColor; CBUFFER_END // 顶点着色器输入结构体 struct Attributes { float4 positionOS : POSITION; // 模型空间顶点位置 float2 uv : TEXCOORD0; // 纹理坐标 float3 normalOS : NORMAL; // 模型空间法线 }; // 顶点着色器输出/片元着色器输入结构体(可变变量) struct Varyings { float4 positionHCS : SV_POSITION; // 齐次裁剪空间位置 float2 uv : TEXCOORD0; float3 positionWS : TEXCOORD1; // 世界空间位置 float3 normalWS : TEXCOORD2; // 世界空间法线 }; // 顶点着色器 Varyings vert(Attributes IN) { Varyings OUT; // 使用Core.hlsl中的TransformObjectToHClip函数转换顶点位置 OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz); // 应用纹理的缩放和偏移 OUT.uv = TRANSFORM_TEX(IN.uv, _MainTex); // 将法线和位置转换到世界空间,用于光照计算 OUT.positionWS = TransformObjectToWorld(IN.positionOS.xyz); OUT.normalWS = TransformObjectToWorldNormal(IN.normalOS); return OUT; } // 片元着色器 half4 frag(Varyings IN) : SV_Target { // 1. 准备表面数据 // 采样纹理 half4 albedoAlpha = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv); half3 albedo = albedoAlpha.rgb; half alpha = albedoAlpha.a; // 准备输入光照计算的数据结构 InputData lightingInput = (InputData)0; lightingInput.positionWS = IN.positionWS; lightingInput.normalWS = normalize(IN.normalWS); // 法线必须归一化 lightingInput.viewDirectionWS = GetWorldSpaceNormalizeViewDir(IN.positionWS); // 获取视角方向 lightingInput.shadowCoord = TransformWorldToShadowCoord(IN.positionWS); // 计算阴影坐标(如有阴影) // 2. 准备表面参数 SurfaceData surfaceData; surfaceData.albedo = albedo; surfaceData.alpha = alpha; surfaceData.specular = _SpecColor.rgb; // 高光颜色 surfaceData.smoothness = _Glossiness; // 平滑度 surfaceData.normalTS = half3(0,0,1); // 切线空间法线(本例未使用法线贴图) surfaceData.emission = half3(0,0,0); surfaceData.occlusion = 1.0; surfaceData.metallic = _Metallic; // 金属度 // 3. 核心:调用URP光照函数进行计算 // UniversalFragmentPBR是URP用于PBR(基于物理渲染)的标准函数 // 对于Blinn-Phong等非PBR模型,可能需要使用UniversalFragmentBlinnPhong或自定义光照 // 这里为了演示通用性,使用PBR函数,并将高光颜色和强度通过_SpecColor和_Glossiness传递 half4 color = UniversalFragmentPBR(lightingInput, surfaceData); return color; } ENDHLSL } } // URP有自己的一套Fallback,通常指向内置的Lit Shader FallBack "Universal Render Pipeline/Lit" }3.3 关键修改点逐行解析
让我们对比两段代码,看看具体改了哪里,以及为什么这么改:
CGPROGRAM->HLSLPROGRAM- 是什么:最直接的标识符替换,告诉Unity这个代码块内是HLSL语言。
- 为什么:URP渲染管线只识别HLSL代码块。
头文件包含
- CG版:通常不需要显式包含很多文件,Unity在背后处理了。
- HLSL版:必须显式包含URP的核心库。
Core.hlsl:提供了空间变换函数(如TransformObjectToHClip,TransformObjectToWorld)、基础数学函数和平台抽象层。Lighting.hlsl:包含了所有光照模型函数(如UniversalFragmentPBR)、光照数据结构(InputData,SurfaceData)和光照循环逻辑。
- 为什么:URP将核心功能模块化,你需要什么就包含什么,这使得Shader更清晰,也便于引擎优化。
纹理与采样器声明
- CG版:
sampler2D _MainTex; - HLSL版:
TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); - 为什么:
TEXTURE2D和SAMPLER是URP定义的宏,它们在不同图形API(如DirectX, OpenGL ES, Vulkan)下会展开为不同的底层类型,确保了跨平台兼容性。采样分离(将纹理对象和采样器状态分开)也是现代图形API的常见做法,有利于性能。
- CG版:
常量缓冲区(CBUFFER)
- CG版:属性变量直接声明。
- HLSL版:所有与材质相关的、每帧不变的属性变量(除了纹理)需要包裹在
CBUFFER_START(UnityPerMaterial)和CBUFFER_END中。 - 为什么:这是为了支持SRP Batcher,这是URP的一项关键性能优化技术。它将所有材质属性数据打包到一块连续的GPU内存中,大幅减少Draw Call之间的状态切换开销。如果不使用CBUFFER,SRP Batcher将无法优化该Shader。
顶点着色器输入/输出结构体
- CG版:在Surface Shader中,
Input结构体通常只包含UV等插值数据,顶点变换由引擎完成。 - HLSL版:我们需要自己定义
Attributes(输入)和Varyings(输出)结构体,并手动编写vert函数完成顶点变换。 - 为什么:URP取消了Surface Shader的黑盒,给了我们完全的控制权。我们需要:
- 将模型空间顶点位置(
positionOS)通过TransformObjectToHClip函数转换到齐次裁剪空间(positionHCS)。 - 将模型空间法线(
normalOS)通过TransformObjectToWorldNormal转换到世界空间(normalWS),用于光照计算。 - 计算世界空间位置(
positionWS)和视角方向(viewDirectionWS)。
- 将模型空间顶点位置(
- CG版:在Surface Shader中,
光照计算
- CG版:
#pragma surface surf BlinnPhong一句搞定,光照是黑盒。 - HLSL版:这是一个多步骤的显式过程:
- 准备
InputData:填充世界空间位置、法线、视角方向、阴影坐标等。 - 准备
SurfaceData:填充表面颜色、高光、平滑度、金属度等材质属性。 - 调用光照函数:如
UniversalFragmentPBR。这个函数内部会处理所有启用的光源(主光、附加光)、阴影、光照衰减等,并返回最终颜色。
- 准备
- 为什么:URP的光照模型是函数式的、可组合的。你可以选择不同的光照函数(PBR, BlinnPhong, Lambert等),也可以自己编写。这种设计透明且灵活,但需要开发者对光照流程有更深的理解。
- CG版:
Tags和Fallback
- Tags:必须添加
"RenderPipeline"="UniversalPipeline",让Unity知道这个Shader是为URP设计的。Pass的LightMode标签必须设置为"UniversalForward",这是URP前向渲染路径的标识。 - Fallback:指向URP内置的
"Universal Render Pipeline/Lit"Shader,确保在缺少某些功能时有合理的回退表现。
- Tags:必须添加
4. 迁移过程中的常见陷阱与解决方案
即使理解了上述原理,在实际操作中仍然会遇到各种报错和诡异的现象。下面是我在多次迁移中总结出的高频问题。
4.1 编译错误:“未识别的标识符”
这是最常见的一类错误,通常是函数或变量未定义。
- 问题:编译器提示
error C3004: unrecognized identifier 'mul'或类似信息。 - 原因与解决:
- 缺少头文件:确保包含了
Core.hlsl。像mul(矩阵乘法)这样的基础函数都在其中定义。 - 函数名错误:URP的函数名可能与CG习惯不同。例如,CG中常用的
UnityObjectToWorldNormal在URP中是TransformObjectToWorldNormal。务必查阅URP的ShaderLibrary文件,不要凭记忆。 - 变量名错误:
Varyings结构体中SV_POSITION语义的变量,在URP的Core.hlsl中通常建议命名为positionHCS或positionCS,但你必须与自己在顶点着色器中的输出名一致。
- 缺少头文件:确保包含了
4.2 渲染错误:物体全黑、全粉或闪烁
这通常是坐标空间错误或光照数据未正确初始化导致的。
- 问题:模型渲染为纯黑、洋红色(Missing Shader的颜色)或不断闪烁。
- 排查步骤:
- 检查顶点位置变换:确保在顶点着色器中,使用
TransformObjectToHClip(IN.positionOS.xyz)来计算positionHCS。直接使用模型空间坐标或错误的变换函数会导致物体不在屏幕上。 - 检查法线向量:在片元着色器中,必须对从顶点着色器插值而来的世界空间法线进行重归一化:
normalize(IN.normalWS)。插值会导致向量长度不为1,影响光照计算的点积结果。 - 检查光照输入:确认
InputData结构体的所有字段都已正确赋值。特别是viewDirectionWS,应使用GetWorldSpaceNormalizeViewDir(IN.positionWS)计算。shadowCoord如果没有阴影可以初始化为float4(0,0,0,0),但最好根据是否有主光源阴影来条件初始化。 - 检查SurfaceData:确保
surfaceData中的albedo,alpha,metallic,smoothness等字段都赋予了合理的值。一个未初始化的half3可能是NaN,导致整个计算失效。
- 检查顶点位置变换:确保在顶点着色器中,使用
4.3 性能问题:SRP Batcher失效
迁移后Shader性能反而不如以前,可能是没有正确启用SRP Batcher。
- 问题:在Frame Debugger中查看,发现Draw Call数量没有因SRP Batcher而合并。
- 检查与解决:
- 确认CBUFFER:所有
uniform材质属性(除了纹理)必须声明在CBUFFER_START(UnityPerMaterial)块内。 - 检查属性命名:纹理的缩放偏移变量命名必须严格遵守
_MainTex_ST的格式(_TextureName+_ST)。 - 查看编译日志:在Shader Inspector面板中,查看编译后的代码,检查是否有“Non-conformant uniform variable”之类的警告。
- 使用简单结构体:确保
Attributes和Varyings结构体尽可能简洁,只包含必要的变量。过多的TEXCOORD可能会影响批处理。
- 确认CBUFFER:所有
4.4 特定功能丢失:阴影、雾效、透明度
Built-in中自动附带的功能,在URP中需要手动开启。
- 阴影接收:
- 需要包含
Shadows.hlsl(通常Lighting.hlsl已包含)。 - 在
InputData中正确计算shadowCoord:TransformWorldToShadowCoord(IN.positionWS)。 - 确保Shader的Pass中包含了阴影投射的Pass,或者使用
UsePass引用URP Lit的阴影投射Pass。
- 需要包含
- 雾效:
- 需要包含
Fog.hlsl。 - 在片元着色器最后,对计算出的颜色应用雾效:
color.rgb = MixFog(color.rgb, inputData.fogCoord);。其中fogCoord通常可以从顶点位置计算得到。
- 需要包含
- 透明度混合/裁剪:
- 需要修改
Tags中的"RenderType"和"Queue"(如"Transparent","AlphaTest")。 - 在Pass中设置混合模式:
Blend SrcAlpha OneMinusSrcAlpha。 - 对于裁剪,在片元着色器中根据Alpha值调用
clip(alpha - _Cutoff);。
- 需要修改
5. 进阶技巧与最佳实践
当你成功完成基础迁移后,下面这些技巧可以帮助你写出更高效、更健壮的URP Shader。
5.1 利用HLSL的现代特性
- 使用
half类型:对于颜色、UV等不需要全精度的数据,使用half(半精度浮点数)而非float,可以减少GPU带宽占用和计算开销。在移动平台上收益明显。 - 避免分支和循环:在片元着色器中,特别是移动端,尽量避免复杂的
if-else分支和循环。可以使用step()、lerp()等函数进行平滑的条件判断。 - 提前裁剪(Early Z):对于不透明物体,确保深度写入开启,并尽量让片元着色器简单,以利于GPU进行提前深度测试,丢弃不需要的片元。
5.2 模块化与代码复用
URP的ShaderLibrary本身就是模块化的典范。你可以借鉴这种思想:
- 将常用函数封装:例如,将计算视差遮挡映射(Parallax Occlusion Mapping)的代码写在一个单独的
.hlsl文件中,然后在多个Shader中#include它。 - 使用多编译变体:通过
#pragma multi_compile指令来管理不同功能组合(如是否启用阴影、是否启用雾效),避免生成一个包含所有功能的巨型Shader。
5.3 调试与优化工具
- Frame Debugger:Unity内置的神器。可以一步步查看每个Draw Call的状态、渲染目标、使用的Shader和属性。是诊断渲染顺序、Overdraw和批处理问题的必备工具。
- Shader Inspector的“Show Generated Code”:点击Shader Inspector中的“Compile and show code”,可以查看Unity为当前平台编译后的最终HLSL/GLSL代码。这对于理解宏展开、排查语法错误非常有帮助。
- 平台差异预判:在编写Shader时,心里要想着目标平台(PC、移动端)。移动端(GLES)对纹理采样次数、算术指令数、寄存器数量等有更严格的限制。URP的
Core.hlsl已经做了很多平台抽象,但仍需注意避免过于复杂的计算。
迁移一个Shader,从能跑到跑得好,再到跑得高效,是一个不断迭代和深入理解的过程。最初的几次迁移可能会充满挫折,但每一次解决报错、修复渲染异常的过程,都会让你对URP的渲染机制、对HLSL语言、乃至对GPU图形流水线有更深一层的认识。这份从CG到HLSL的“地图”和“工具箱”已经交给你了,接下来,就带着你那些充满创意的Shader想法,在URP的世界里尽情探索吧。记住,最好的学习方式永远是动手实践,遇到问题,就回头来查查这份指南,或者直接去翻看URP包里的那些.hlsl源码,那里藏着所有问题的答案。
