三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Unity URP Shader迁移:从CG到HLSL的完整指南与实战避坑

Unity URP Shader迁移:从CG到HLSL的完整指南与实战避坑

1. 项目概述:为什么URP Shader必须从CG迁移到HLSL?

如果你是一个从Unity内置渲染管线(Built-in Render Pipeline)时代走过来的开发者,手头肯定积累了不少用CG语言编写的Shader。当项目升级到通用渲染管线(Universal Render Pipeline, URP)时,一个绕不开的“硬骨头”就是把这些老Shader从CG迁移到HLSL。这不仅仅是把CGPROGRAM改成HLSLPROGRAM那么简单,背后涉及到语法差异、函数库替换、渲染管线逻辑变更等一系列深坑。我最近刚完成一个中型项目的完整Shader迁移,过程堪称一部“血泪史”。这篇文章,我就以一个踩坑者的身份,和你详细聊聊从CG到HLSL迁移的核心思路、具体步骤、那些让人抓狂的细节,以及如何高效避坑。文末会附上一个从简单到复杂的完整代码对比案例,你可以直接拿去参考。

简单来说,迁移的核心驱动力是性能和兼容性。Unity官方在URP中明确将HLSL作为首选着色器语言。虽然URP仍然支持CGPROGRAM块,但使用它会带来两个致命问题:第一,Unity会自动引入内置渲染管线的Shader库,这可能导致与URP的SRP Shader库中的宏和函数发生命名冲突,引发难以调试的编译错误或渲染异常;第二,也是更重要的,使用CG的Shader将无法兼容SRP Batcher。SRP Batcher是URP提升渲染效率的核心机制,它能大幅减少CPU向GPU提交渲染数据时的开销。如果你的Shader不兼容,在复杂场景下的Draw Call和CPU耗时可能会居高不下,性能优化就无从谈起。因此,对于任何打算在URP下长期维护和优化的项目,将Shader迁移到HLSL不是可选项,而是必选项。

2. 迁移核心思路与准备工作

2.1 理解CG与HLSL的核心差异

在动手改代码之前,我们必须先理清CG和HLSL在URP语境下的主要不同点。这能帮助我们在迁移时建立正确的思维模型,而不是盲目地逐行替换。

1. 头文件与函数库的彻底更换:在CG时代,我们习惯使用UnityCG.cginc等内置管线提供的头文件。而在URP的HLSL中,这些全部被废弃。我们需要转向URP提供的SRP Shader库。最核心的头文件是Core.hlsl,它就像一个新的“总入口”,会自动包含Common.hlslSpaceTransforms.hlsl等子模块。这意味着,所有空间变换(如UnityObjectToClipPos)、光照计算、雾效等函数,都需要使用URP库中提供的新版本。

2. 语义(Semantics)的细微变化:顶点着色器的输出(从顶点着色器传递到片元着色器的结构体)中,顶点位置必须使用SV_POSITION语义。在CG中常用的POSITIONSV_POSITION混用或隐式转换,在HLSL中要求更严格。此外,一些用于屏幕空间坐标的语义也可能需要调整。

3. 内置变量与宏的映射关系:CG中大量使用的内置变量,如_Time_SinTime_ProjectionParams等,在URP HLSL中仍然存在,但通常通过特定的宏或函数来访问。例如,时间变量可以通过GetCurrentTime()_TimeParameters来获取。矩阵变量,如UNITY_MATRIX_MVP,被更模块化的变换函数(如TransformObjectToHClip)所取代。

4. Surface Shader的消亡与替代方案:对于复杂的、基于物理的着色,内置管线中的Surface Shader在URP中不再被支持。如果你的老Shader是Surface Shader写的,迁移工作量最大。你需要将其拆解为顶点/片元着色器,并手动实现或使用URP的Lit Shader框架(如LitInput.hlslLighting.hlsl)来重构光照模型。

注意:在开始迁移前,强烈建议在Unity中创建一个干净的URP测试项目,并将URP的Package Samples(特别是“Shader”示例)导入。这里面包含了大量标准的HLSL Shader代码,是你迁移过程中最重要的参考手册。

2.2 建立高效的迁移工作流

面对成百上千个Shader文件,一个有序的工作流能极大提升效率并减少错误。

第一步:备份与分类。将整个Shaders文件夹完整备份。然后根据复杂程度对Shader进行分类:

  • 简单Unlit Shader:只做颜色、贴图、顶点动画,无光照。这类最容易迁移,适合练手。
  • 标准光照模型Shader:使用了Standard、Standard (Specular setup)或自定义光照模型的Shader。这类需要处理光照相关函数和头文件。
  • Surface Shader:如前所述,最复杂,几乎需要重写。
  • 屏幕后处理Shader:需要特别注意全屏绘制、采样源纹理等操作在URP中的变化。

第二步:搭建测试场景。创建一个包含各种材质(对应不同类型Shader)的测试场景。确保场景中有不同光源(平行光、点光源)、不同材质的物体(金属、非金属、透明)以及后处理效果。这个场景将作为你每个Shader迁移后的“试金石”,用于快速验证渲染结果是否正确。

第三步:逐个击破,从简到繁。不要试图一次性迁移所有Shader。从一个最简单的、功能单一的Unlit Shader开始。按照下文将提到的步骤成功迁移一个后,你就建立了信心和模式,再逐步处理更复杂的。每迁移完一个,立即在测试场景中验证。

3. 核心语法与代码迁移实战解析

现在,我们进入最核心的实操环节。我将通过一个具体的案例,将一个内置管线的CG Shader逐步迁移为URP HLSL Shader,并解释每一处修改的原因和细节。

原始CG Shader (Built-in RP):这是一个简单的漫反射贴图Shader,我们以此为例。

Shader "Custom/OldDiffuse" { Properties { _MainTex ("Texture", 2D) = "white" {} _Color ("Color", Color) = (1,1,1,1) } SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 worldNormal : TEXCOORD1; }; sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.worldNormal = UnityObjectToWorldNormal(v.normal); return o; } fixed4 frag (v2f i) : SV_Target { // 简单漫反射光照(仅主平行光) fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz); fixed diff = max(0, dot(i.worldNormal, worldLightDir)); fixed4 col = tex2D(_MainTex, i.uv) * _Color; col.rgb *= diff * _LightColor0.rgb; return col; } ENDCG } } }

3.1 基础结构迁移:ShaderLab标签与HLSL块

首先,我们需要修改最外层的ShaderLab结构,使其兼容URP。

  1. 修改SubShader Tags:这是告诉Unity这个Shader用于URP管线的关键。将Tags { "RenderType"="Opaque" }修改为Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" }"RenderPipeline"="UniversalPipeline"这个标签是URP Shader的身份证。
  2. 替换程序块关键字:CGPROGRAMENDCG分别替换为HLSLPROGRAMENDHLSL。这是语言切换的起点。
  3. 更新头文件:删除#include "UnityCG.cginc"。取而代之的是引入URP的核心库:#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"。对于涉及光照的Shader,我们可能还需要Lighting.hlsl,但第一步我们先处理基础变换。

此时,Shader的框架已经改为HLSL,但内部函数全部会报错。我们继续。

3.2 顶点与片元着色器函数迁移

这是迁移的核心,涉及空间变换、变量传递和光照计算。

  1. 结构体与语义:appdatav2f结构体可以保留原名,但内部变量名可以遵循URP的常见命名习惯(如positionOS代表物体空间位置)。语义部分,v2f中的顶点位置必须使用: SV_POSITION。其他如纹理坐标、法线等使用: TEXCOORDn
  2. 空间变换函数:CG中的UnityObjectToClipPos(v.vertex)需要替换。在URP中,我们使用TransformObjectToHClip()函数。这个函数定义在Core.hlsl包含的SpaceTransforms.hlsl中。因此,修改为o.positionHCS = TransformObjectToHClip(v.positionOS.xyz);。注意,我同时将变量名从vertex改为了positionOS,将输出从vertex改为了positionHCS(Homogeneous Clip Space),这更符合URP的命名规范。
  3. 纹理变换:CG中的宏TRANSFORM_TEX(v.uv, _MainTex)依赖于_MainTex_ST变量。在HLSL中,我们通常需要手动计算。可以保留_MainTex_ST这个变量,然后在顶点着色器中计算:o.uv = v.uv * _MainTex_ST.xy + _MainTex_ST.zw;。另一种更URP风格的做法是使用一个专门的函数或直接在片元着色器中处理,但手动计算是最直接透明的。
  4. 法线变换:UnityObjectToWorldNormal(v.normal)需要替换。URP中提供了TransformObjectToWorldNormal()函数。因此修改为o.worldNormal = TransformObjectToWorldNormal(v.normalOS);

3.3 光照与属性声明的迁移

光照是迁移中最容易出错的部分,因为URP的光照架构与内置管线截然不同。

  1. 属性与变量声明:Properties块中的声明方式不变。但在HLSL代码块中,声明纹理和采样器的方式发生了变化。在URP中,为了支持更现代的API(如Vulkan、Metal),推荐使用TEXTURE2D(_MainTex);SAMPLER(sampler_MainTex);来声明。同时,我们需要定义一个常量缓冲区来存放_MainTex_ST_Color这些每材质属性。通常使用CBUFFER_START(UnityPerMaterial)CBUFFER_END宏包裹它们。这对于SRP Batcher兼容性至关重要。
  2. 光照数据获取:CG中直接使用_WorldSpaceLightPos0_LightColor0来获取主平行光的方向和颜色。在URP中,这不再适用。URP的光照信息需要通过其光照系统来获取。对于简单的向前渲染(Forward Renderer),我们可以使用GetMainLight()函数。这个函数返回一个Light结构体,其中包含了方向、颜色、衰减等信息。要使用它,需要包含Lighting.hlsl头文件:#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
  3. 重写片元着色器光照计算:在片元着色器中,我们不再直接访问内置光照变量。而是:
    • 调用GetMainLight()获取主光源数据。
    • 使用返回的Light结构体中的directioncolor成员进行计算。

迁移后的HLSL Shader核心代码框架如下:

Shader "Custom/URPDiffuse" { Properties { _MainTex ("Texture", 2D) = "white" {} _Color ("Color", Color) = (1,1,1,1) } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" } Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; float3 normalOS : NORMAL; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; float3 worldNormal : TEXCOORD1; }; // 声明纹理和采样器(URP风格) TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); // 将材质属性放入常量缓冲区,以兼容SRP Batcher CBUFFER_START(UnityPerMaterial) float4 _MainTex_ST; half4 _Color; CBUFFER_END Varyings vert (Attributes IN) { Varyings OUT; OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz); OUT.uv = TRANSFORM_TEX(IN.uv, _MainTex); // 这里可以保留宏,但需确保_MainTex_ST在CBUFFER中 OUT.worldNormal = TransformObjectToWorldNormal(IN.normalOS); return OUT; } half4 frag (Varyings IN) : SV_Target { // 获取主光源信息 Light mainLight = GetMainLight(); float3 worldLightDir = mainLight.direction; half3 lightColor = mainLight.color; // 计算漫反射 float3 normalizedNormal = normalize(IN.worldNormal); half diff = max(0, dot(normalizedNormal, worldLightDir)); half3 diffuse = diff * lightColor; // 采样纹理并应用颜色 half4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv); col *= _Color; col.rgb *= diffuse; return col; } ENDHLSL } } }

实操心得:TRANSFORM_TEX宏在URP的Core.hlsl中默认可能不存在。如果编译报错,最稳妥的方式是手动实现纹理变换:OUT.uv = IN.uv * _MainTex_ST.xy + _MainTex_ST.zw;。这能让你更清楚地理解变换过程,避免对隐藏宏的依赖。

4. 迁移过程中的“深坑”与排查实录

即使按照上述步骤操作,在实际迁移中你依然会遇到各种诡异问题。下面是我踩过的一些典型“坑”及其解决方案。

4.1 编译错误:“未识别的标识符”或“函数重载不明确”

这是最常见的问题,根本原因通常是头文件包含错误或函数签名不匹配。

  • 场景1:TransformObjectToWorldNormal未定义。

    • 排查:检查是否包含了Core.hlslTransformObjectToWorldNormal函数定义在Common.hlsl中,而Core.hlsl会自动包含它。如果还报错,可能是函数签名问题。该函数通常接收一个float3参数。确保你传递的是float3类型,而不是half3或带齐次坐标的float4
    • 解决:明确包含#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl",并检查输入参数类型。
  • 场景2:GetMainLight返回类型不匹配或未定义。

    • 排查:首先确认是否包含了Lighting.hlsl。其次,GetMainLight()函数可能需要输入参数。查看URP的文档或示例,常见的用法是GetMainLight(GetShadowCoord(IN.shadowCoord))GetMainLight()。如果你不需要阴影,使用无参版本。确保你的Varyings结构体中是否包含了阴影坐标(如float4 shadowCoord : TEXCOORD2;),并在顶点着色器中通过TransformWorldToShadowCoord(positionWS)计算。
    • 解决:对于无阴影的简单光照,直接使用Light mainLight = GetMainLight();。如果报错,尝试查找你使用的URP版本对应的Lighting.hlsl文件,查看GetMainLight的确切函数签名。
  • 场景3:SAMPLE_TEXTURE2D宏报错。

    • 排查:SAMPLE_TEXTURE2D是URP定义的宏,用于跨平台纹理采样。它需要纹理和采样器状态两个参数。确保你使用TEXTURE2D()SAMPLER()宏来声明纹理和采样器,而不是旧的sampler2D
    • 解决:正确的声明和采样方式如下:
      TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); ... half4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv);

4.2 渲染错误:物体变黑、变粉或闪烁

编译通过但渲染结果不对,问题通常出在数据传递或计算上。

  • 场景1:物体完全变黑(无光照)。

    • 排查:首先检查法线数据。在片元着色器中,打印(通过返回颜色)IN.worldNormal的值,看是否在预期范围内(归一化的法线分量在[-1,1])。可能是顶点着色器中法线变换错误,或者片元着色器中忘记对插值后的法线进行normalize()插值后的法线长度会变短,必须重新归一化!
    • 解决:在片元着色器中,务必对IN.worldNormal进行归一化:float3 normalWS = normalize(IN.worldNormal);
  • 场景2:物体呈现洋红色(Missing粉色)。

    • 排查:这是Shader编译成功但渲染所需数据严重缺失的典型表现。最常见的原因是顶点着色器输出的SV_POSITION语义数据有问题。检查TransformObjectToHClip函数的输入是否正确(是.xyz而不是整个float4)。另一个可能原因是深度测试(ZTest)或深度写入(ZWrite)设置错误,导致物体被提前丢弃或深度冲突。
    • 解决:确保顶点位置变换正确。在Pass块中显式设置深度状态:ZWrite OnZTest LEqual
  • 场景3:纹理采样颜色错误或偏移。

    • 排查:检查UV坐标。如果使用了手动纹理变换 (_MainTex_ST),确认计算是否正确。另外,URP中纹理的Wrap Mode和Filter Mode设置也可能通过采样器状态影响结果,但SAMPLER(sampler_MainTex)默认使用纹理自身的导入设置。
    • 解决:在片元着色器中,尝试直接返回UV坐标作为颜色 (return half4(IN.uv, 0, 1);),检查UV是否在[0,1]范围内且分布正确。

4.3 性能与兼容性问题

  • 问题:Shader不兼容SRP Batcher,导致性能下降。

    • 排查:在Unity编辑器的Frame Debugger或Render Pipeline Stats中查看,如果该材质对应的渲染批次没有被打包,很可能是不兼容。不兼容的主要原因之一是材质属性没有正确声明在CBUFFER_START(UnityPerMaterial)块中。
    • 解决:确保所有在Properties中声明并在HLSL代码中使用的变量(如_Color,_MainTex_ST),以及所有在多个Pass间共享的Uniform变量,都定义在UnityPerMaterial常量缓冲区内。同时,确保TEXTURE2DSAMPLER的声明在CBUFFER之外。
  • 问题:在移动平台或WebGL上编译失败或运行异常。

    • 排查:HLSL代码中可能使用了高精度类型(如float)或某些只在特定平台支持的函数。移动平台对精度和指令数更敏感。
    • 解决:遵循URP的移动端最佳实践:在片元着色器中尽量使用half精度(尤其是在颜色计算时)。使用URP提供的跨平台宏和函数(如SAMPLE_TEXTURE2D)。避免在片元着色器中进行复杂的循环或分支判断。

下表总结了迁移过程中的关键检查点:

问题现象可能原因排查与解决步骤
编译错误:未识别的标识符1. 头文件未包含
2. 函数名拼写错误
3. 变量未声明
1. 检查#include路径是否正确
2. 对照URP示例代码核对函数名
3. 确认所有使用变量都已声明(注意CBUFFER内外)
物体渲染为洋红色1. 顶点位置输出 (SV_POSITION) 错误
2. 深度测试/写入配置错误
3. Pass渲染状态异常
1. 检查TransformObjectToHClip输入输出
2. 在Pass中显式设置ZWrite OnZTest LEqual
3. 简化Shader,逐步添加功能定位问题
光照异常(全黑/过亮)1. 法线未归一化
2. 光源数据获取错误
3. 光照计算模型错误
1. 片元着色器中对法线执行normalize()
2. 确认包含了Lighting.hlsl,正确调用GetMainLight()
3. 输出中间计算值(如diffuse)到颜色进行调试
纹理显示错误1. UV坐标计算错误
2. 纹理/采样器声明方式错误
3. 纹理导入设置问题
1. 直接输出UV值作为颜色检查
2. 使用TEXTURE2D/SAMPLER声明,SAMPLE_TEXTURE2D采样
3. 检查纹理的Wrap Mode和Filter Mode
SRP Batcher不兼容1. 材质属性未放入CBUFFER
2. 多个Pass间属性声明不一致
1. 将所有Properties中定义的变量放入CBUFFER_START(UnityPerMaterial)
2. 确保所有Pass使用相同的属性声明方式

5. 完整代码对比与进阶迁移技巧

最后,我们来看一个更完整的、包含简单高光的Blinn-Phong Shader的迁移前后对比,并分享一些进阶技巧。

CG版本 (Built-in RP) 核心光照部分:

// ... (顶点着色器与结构体同上例类似) fixed4 frag (v2f i) : SV_Target { fixed3 worldNormal = normalize(i.worldNormal); fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz); fixed3 worldViewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz); // 漫反射 fixed diff = max(0, dot(worldNormal, worldLightDir)); fixed3 diffuse = diff * _LightColor0.rgb; // 高光 (Blinn-Phong) fixed3 halfDir = normalize(worldLightDir + worldViewDir); fixed spec = pow(max(0, dot(worldNormal, halfDir)), _Gloss); fixed3 specular = _SpecColor.rgb * spec * _LightColor0.rgb; fixed4 col = tex2D(_MainTex, i.uv); col.rgb *= (_Color.rgb * diffuse + specular); return col; }

HLSL版本 (URP) 核心光照部分:

// ... (需包含Core.hlsl和Lighting.hlsl,结构体中需有worldPos) half4 frag (Varyings IN) : SV_Target { // 归一化向量 float3 normalWS = normalize(IN.worldNormal); float3 positionWS = IN.worldPos; // 获取主光源 Light mainLight = GetMainLight(); float3 lightDir = mainLight.direction; half3 lightColor = mainLight.color; // 计算视图方向 float3 viewDirWS = GetWorldSpaceNormalizeViewDir(positionWS); // URP提供的便捷函数 // 漫反射 half diff = max(0, dot(normalWS, lightDir)); half3 diffuse = diff * lightColor; // 高光 (Blinn-Phong) float3 halfDir = normalize(lightDir + viewDirWS); half spec = pow(max(0, dot(normalWS, halfDir)), _Gloss); half3 specular = _SpecColor.rgb * spec * lightColor; // 采样纹理 half4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv); col.rgb *= (_Color.rgb * diffuse + specular); // 应用环境光(URP中通常通过GetMainLight之外的函数获取) // half3 ambient = SampleSH(normalWS); // 示例:采样球谐光照 // col.rgb += ambient * col.rgb; return col; }

进阶技巧与注意事项:

  1. 使用URP内置工具函数:GetWorldSpaceNormalizeViewDir(positionWS)用于获取视图方向,比手动计算摄像机位置更可靠。SampleSH()用于采样环境光(球谐光照)。多查阅Lighting.hlslCommon.hlsl,你会发现很多“轮子”已经造好了。
  2. 处理多光源:对于需要多光源照明的物体,URP有AdditionalLights.hlsl库。你可以遍历GetAdditionalLightsCount()并使用GetAdditionalLight(lightIndex, positionWS)来获取每个额外光源的信息,然后在循环中累加光照贡献。
  3. 阴影处理:如果需要接收阴影,需要在Varyings中添加阴影坐标shadowCoord,在顶点着色器中计算它(通常使用TransformWorldToShadowCoord(positionWS)),并在片元着色器中将它传递给GetMainLight(shadowCoord)函数。阴影贴图的采样和比对由URP内部完成。
  4. 透明与混合:如果Shader是透明的,需要正确设置Tags { "Queue"="Transparent" "RenderType"="Transparent" }和混合模式Blend SrcAlpha OneMinusSrcAlpha。深度写入通常需要关闭:ZWrite Off
  5. 调试利器:善用Frame DebuggerRender Pipeline Debugger(Window -> Analysis -> Render Pipeline Debugger)。它们可以直观地查看每个Draw Call的渲染状态、Shader属性值,是排查渲染问题的终极武器。

迁移过程就像给老房子更换地基和管线,初期阵痛难免,但一旦完成,你的项目就建立在URP这个更现代、更高效的基础之上。最大的体会是,不要畏惧去阅读URP包内的原生Shader代码,它们是最好的老师。从最简单的Shader开始,成功一个,就总结一套模式,然后逐步应用到更复杂的Shader上。遇到报错,优先检查头文件、函数签名和数据类型,利用好Unity Console的详细错误信息。希望我踩过的这些坑,能为你照亮迁移的道路。

← 返回列表