ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Unity SRP可编程渲染管线:从内置管线到URP的架构迁移与实战

2026/10/1 5:24:01 拓冰建站 浏览量
Unity SRP可编程渲染管线:从内置管线到URP的架构迁移与实战 Shader写多了早晚会撞上SRP这堵墙。刚开始学Unity那会儿我用的还是内置管线写个顶点片元着色器#pragma vertex vert加#pragma fragment frag编译能过、画面能出就觉得自己会了。直到某天项目要接URP我把老Shader拖进去满屏粉红报错信息里全是LightMode标签不匹配、CBUFFER未定义、_MainLightPosition找不到。那一刻我才意识到之前写的那些Shader其实一直活在别人搭好的舞台上而SRP就是把搭舞台的权力交还给你——代价是你得先搞懂舞台是怎么搭的。这篇东西写给两类人一类是已经能写简单Shader、但每次看到RenderPipelineAsset、ScriptableRenderContext就头大的开发者另一类是想从内置管线迁移到URP/HDRP却被底层机制卡住的人。我不会只丢给你几个API名字而是把SRP从为什么要有它到一帧画面怎么被它画出来这条链路拆开讲中间穿插我自己踩过的坑和验证过的结论。读完你应该能明白SRP不是一套新Shader语法而是一套重新组织渲染流程的架构Shader只是它调用的一个环节。1. 从内置管线的黑盒说起SRP到底想解决什么问题1.1 内置管线里那些看不见的手用内置管线写Shader时你有没有想过一个问题UnityCG.cginc里那些_WorldSpaceCameraPos、unity_ObjectToWorld、各种光照变量是谁在什么时候塞进去的答案是C侧的渲染引擎。内置管线的渲染逻辑是硬编码在引擎里的顺序大致是清屏、画不透明物体、画天空盒、画透明物体、后处理。这个顺序你改不了光照模型也基本固定前向渲染就是逐像素算几盏灯延迟渲染就是G-Buffer那一套。这种设计的好处是省心坏处是不透明。比如你想做一个风格化的卡通渲染需要精确控制光照衰减曲线、需要自定义阴影的采样方式、需要在特定阶段插入自己的全屏Pass——在内置管线里你只能靠OnRenderImage、CommandBuffer这些外挂手段去蹭蹭得别扭不说还经常和引擎自己的流程打架。我印象最深的是做二次元描边内置管线里想拿到深度法线做边缘检测得额外挂一个相机渲染深度图性能和流程都很拧巴。1.2 SRP的核心思路把渲染流程变成C#脚本SRPScriptable Render Pipeline可编程渲染管线做的事情说白了就是把原本写死在C里的渲染流程搬到C#层让你用脚本去描述这一帧该怎么画。你不再是被动接受引擎的渲染顺序而是主动编写一个渲染指挥官告诉引擎先设置哪个渲染目标、用哪个Shader、画哪些物体、什么时候切换相机、什么时候做后处理。这个转变的意义在于渲染流程从配置变成了代码。代码意味着可组合、可调试、可针对项目定制。URP和HDRP本质上就是Unity官方用SRP写出来的两套参考实现——URP面向移动端和中等画质HDRP面向高端主机和PC。你完全可以在SRP之上写自己的管线虽然大多数项目没必要这么做但理解这层关系是理解URP/HDRP行为的前提。1.3 一个关键认知SRP不等于Shader很多人一听到SRP就以为是学新Shader写法其实SRP的主体是C#的渲染逻辑Shader只是被它调用的资源。SRP管的是什么时候、用什么参数、画哪些物体Shader管的是这个物体每个像素长什么样。两者通过DrawingSettings、RenderStateBlock这些结构体对接。搞清楚这个分工你就不会在写SRP时纠结这个逻辑该放C#还是Shader——流程控制放C#像素计算放Shader边界清晰。2. SRP的骨架ScriptableRenderContext与RenderPipeline2.1 RenderPipeline一帧的入口SRP的入口是一个继承自RenderPipeline的类它必须实现一个方法protected override void Render(ScriptableRenderContext context, Camera[] cameras) { // 这一帧的所有渲染指令都从这里开始 }这个Render方法就是你的主循环。引擎每帧会调用它把当前所有需要渲染的相机和上下文交给你。注意这里的context类型是ScriptableRenderContext它是SRP最核心也最容易被误解的东西。2.2 ScriptableRenderContext指令的排队区ScriptableRenderContext不是渲染器本身它更像一个指令缓冲区。你调用context.DrawRenderers(...)、context.ExecuteCommandBuffer(...)这些调用并不会立刻执行而是被记录下来等到某个时机通常是context.Submit()才真正提交给底层渲染线程。这个设计的原因在于性能渲染指令的收集和实际执行分离可以让引擎批量处理、减少线程同步开销。但对开发者来说它带来一个常见的坑——你在C#里改了某个状态Shader里不一定马上能看到。比如你设置了一个全局纹理如果没通过CommandBuffer或者没在正确的时机提交Shader采样到的可能是上一帧的值。我调试SRP时遇到过全局变量不生效的问题排查半天发现是context的指令还没提交Shader就已经开始采样了。理解context的关键是记住它是待办清单不是执行者。所有对context的调用都是在往清单上写条目Submit才是按下执行按钮。2.3 CommandBuffer更细粒度的指令容器CommandBuffer是比context更灵活的指令容器。你可以创建它、往里面塞各种渲染命令设置渲染目标、清屏、画Mesh、设置全局变量然后通过context.ExecuteCommandBuffer(cmd)把它交给上下文。它的好处是可以复用、可以精确控制执行时机、可以在多个相机之间共享。一个典型的用法是在渲染不透明物体前用CommandBuffer设置好全局的光照参数然后ExecuteCommandBuffer再DrawRenderers。这样光照参数在绘制时一定是最新的。相比之下如果你直接用context.SetGlobalVector如果存在这样的API时机就不那么可控。var cmd CommandBufferPool.Get(SetupLighting); cmd.SetGlobalVector(_MainLightPosition, lightPos); cmd.SetGlobalColor(_MainLightColor, lightColor); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd);注意这里用了CommandBufferPool这是SRP里必须养成的习惯——每帧创建大量CommandBuffer会造成GC压力用池子复用是标准做法。我早期写SRP时忘了Release跑一会儿就内存飙升这个坑很典型。2.4 一个最小SRP的完整骨架把上面几块拼起来一个能跑的最小SRP大概长这样public class MyPipeline : RenderPipeline { CommandBuffer _cmd new CommandBuffer(); protected override void Render(ScriptableRenderContext context, Camera[] cameras) { foreach (var camera in cameras) { context.SetupCameraProperties(camera); _cmd.Clear(); _cmd.ClearRenderTarget(true, true, Color.clear); context.ExecuteCommandBuffer(_cmd); var drawingSettings new DrawingSettings( new ShaderTagId(SRPDefaultUnlit), new SortingSettings(camera)); var filteringSettings new FilteringSettings(RenderQueueRange.opaque); context.DrawRenderers( context.Cull(ref cullingParams), ref drawingSettings, ref filteringSettings); context.Submit(); } } }这段代码虽然简陋但包含了SRP的核心动作设置相机属性、清屏、剔除、绘制、提交。URP的UniversalRenderPipeline.Render方法本质上就是这个骨架的复杂化版本加了光照、阴影、后处理、多Pass等环节。3. 一帧画面的诞生SRP渲染流程的完整拆解3.1 相机设置SetupCameraProperties做了什么context.SetupCameraProperties(camera)这一步很关键它把相机的视图矩阵、投影矩阵、裁剪参数等写入渲染状态同时设置了一些内置的Shader变量比如unity_MatrixVP、_WorldSpaceCameraPos。如果你跳过这一步Shader里拿到的相机矩阵就是错的画面会扭曲或者全黑。这里有个容易忽略的点SetupCameraProperties默认会设置相机的渲染目标为当前激活的RenderTexture。如果你在做多相机渲染比如UI相机叠加在场景相机上需要小心处理渲染目标的切换否则会出现画面覆盖或者深度冲突。我在做分屏渲染时就被这个坑过两个相机抢同一个渲染目标结果只有一个能显示。3.2 剔除Cull把不需要的物体筛掉context.Cull(ref cullingParams)返回一个CullingResults里面包含了经过视锥剔除、层剔除、遮挡剔除后剩下的可见物体。这个结果会被DrawRenderers使用。剔除是性能优化的第一道关卡SRP把剔除结果暴露出来意味着你可以基于它做更精细的控制——比如只画某个Layer的物体、或者根据距离动态调整绘制列表。CullingResults还有一个重要用途它是获取光照数据的入口。cullingResults.visibleLights能拿到所有影响当前相机的可见光源URP的主光源、附加光源就是从这里面筛选出来的。如果你自己写SRP要做光照这一步是绕不开的。3.3 绘制DrawRenderers的参数玄机DrawRenderers是SRP里最核心的绘制调用它的参数决定了画什么、怎么画参数作用常见取值DrawingSettings指定Shader Tag、排序方式、每物体数据ShaderTagId、SortingSettingsFilteringSettings过滤渲染队列、Layer、材质RenderQueueRange、LayerMaskRenderStateBlock覆盖渲染状态深度、混合、剔除默认或自定义DrawingSettings里的ShaderTagId是连接C#和Shader的桥梁。你在Shader的Pass里写Tags { LightMode SRPDefaultUnlit }然后在C#里用new ShaderTagId(SRPDefaultUnlit)去匹配匹配上的Pass才会被这个DrawRenderers调用。URP里常见的Tag有UniversalForward、ShadowCaster、DepthOnly、Universal2D等每个对应不同的渲染阶段。这里有个实战经验如果你自定义SRPShaderTagId的名字要和Shader里的LightMode完全一致大小写敏感。我曾经因为把UniversalForward写成Universalforward排查了半小时才发现是大小写问题。这种错误编译器不报只是物体不显示很隐蔽。3.4 提交Submit才是真正的发令枪前面所有对context的调用都是排队context.Submit()才是真正把这些指令提交给渲染线程。一帧里可以多次Submit但每次Submit都有开销所以通常是一帧一次放在所有绘制指令之后。需要特别注意的是Submit之后context的状态会被重置你不能在Submit之后再往同一个context里塞指令然后期望它们属于同一批。如果确实需要分阶段提交比如先提交阴影Pass再提交主Pass要确保每个阶段的状态设置是完整的。4. Shader在SRP下的生存法则CBUFFER、Tag与变体4.1 CBUFFERSRP的常量缓冲区规范内置管线里Shader的常量可以直接声明引擎会自动处理。但SRP下尤其是URP要求把常量按更新频率分组放进CBUFFERCBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; float4 _BaseColor; CBUFFER_END CBUFFER_START(UnityPerDraw) float4x4 unity_ObjectToWorld; CBUFFER_END为什么要这么做因为现代图形API如DX11、Vulkan、Metal使用常量缓冲区来批量上传常量按更新频率分组能减少CPU到GPU的数据传输。UnityPerMaterial里的常量每个材质不同UnityPerDraw里的每个物体不同UnityPerFrame里的每帧不同。SRP通过CBUFFER把这些分组显式化让引擎知道什么时候该更新哪一块。这个规范带来的一个直接后果是SRP下Shader的常量声明必须严格遵循CBUFFER结构否则会出现变量值不对或者编译报错。我从内置管线迁移Shader时最常改的就是把散落的常量声明包进CBUFFER。URP还提供了UnityPerMaterial的自动生成机制通过ShaderGUI和MaterialPropertyDrawer进一步简化了这个过程。4.2 LightMode TagPass的身份证SRP下每个Pass的LightModeTag决定了它被哪个渲染阶段调用。以URP为例UniversalForward主前向渲染Pass画不透明和透明物体ShadowCaster阴影投射Pass生成阴影贴图DepthOnly深度预Pass用于深度图DepthNormals深度法线Pass用于SSAO等效果Universal2D2D渲染器专用如果你写了一个Pass但没设LightMode或者设了一个SRP不认识的LightMode这个Pass就不会被任何DrawRenderers调用物体自然不显示。这是迁移Shader时粉红物体之外最常见的物体消失原因。4.3 Shader变体与KeywordSRP下的编译控制SRP大量使用Shader变体来处理不同功能组合。比如URP的Lit Shader通过_MAIN_LIGHT_SHADOWS、_ADDITIONAL_LIGHTS、_NORMALMAP等Keyword编译出几十上百个变体。这些变体在构建时会被裁剪只保留实际用到的组合。这里有个性能陷阱Keyword组合爆炸会导致构建时间变长、包体变大。我见过一个项目因为Shader里Keyword太多构建时编译了几千个变体打包时间翻倍。解决办法是用shader_feature代替multi_compile前者只编译用到的变体或者用ShaderVariantCollection精确控制需要预编译的变体。另外SRP下#pragma multi_compile的行为和内置管线略有不同URP提供了#pragma multi_compile _ _MAIN_LIGHT_SHADOWS这种带下划线的写法下划线代表该Keyword关闭的变体。这个细节不搞清楚很容易写出编译不过或者变体数量失控的Shader。5. 从零验证手写一个能跑的极简SRP5.1 环境准备与项目结构要验证上面的理论最好的办法是自己写一个极简SRP。新建一个3D项目创建以下文件结构Assets/ MySRP/ MyRenderPipeline.cs MyRenderPipelineAsset.cs MyShader.shaderMyRenderPipelineAsset继承RenderPipelineAsset负责创建管线实例[CreateAssetMenu(menuName Rendering/MySRP)] public class MyRenderPipelineAsset : RenderPipelineAsset { protected override RenderPipeline CreatePipeline() { return new MyRenderPipeline(); } }然后在Project Settings的Graphics里把这个Asset设为渲染管线。这一步做完场景会变黑或者变粉因为原来的内置Shader不再被支持这是正常的。5.2 编写极简管线逻辑MyRenderPipeline的核心逻辑就是前面2.4节的骨架但需要补全剔除参数protected override void Render(ScriptableRenderContext context, Camera[] cameras) { foreach (var camera in cameras) { if (!camera.TryGetCullingParameters(out var cullingParams)) continue; context.SetupCameraProperties(camera); _cmd.Clear(); _cmd.ClearRenderTarget(true, true, Color.gray); context.ExecuteCommandBuffer(_cmd); var cullingResults context.Cull(ref cullingParams); var sortingSettings new SortingSettings(camera) { criteria SortingCriteria.CommonOpaque }; var drawingSettings new DrawingSettings( new ShaderTagId(MySRP), sortingSettings); var filteringSettings new FilteringSettings( RenderQueueRange.opaque); context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings); context.Submit(); } }注意这里用的ShaderTagId是MySRP所以我们的Shader里LightMode也要写MySRP。5.3 配套Shader的写法Shader MySRP/Unlit { Properties { _BaseColor (Color, Color) (1,1,1,1) } SubShader { Tags { RenderType Opaque } Pass { Tags { LightMode MySRP } HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl CBUFFER_START(UnityPerMaterial) float4 _BaseColor; CBUFFER_END struct Attributes { float4 positionOS : POSITION; }; struct Varyings { float4 positionCS : SV_POSITION; }; Varyings vert(Attributes input) { Varyings output; output.positionCS TransformObjectToHClip(input.positionOS.xyz); return output; } half4 frag(Varyings input) : SV_Target { return _BaseColor; } ENDHLSL } } }把这个Shader挂到一个Cube上如果管线配置正确Cube会显示为设定的颜色背景是灰色。这一步跑通说明你已经掌握了SRP的最小闭环。5.4 验证过程中的常见失败点现象可能原因排查方向全黑没清屏或相机矩阵未设置检查ClearRenderTarget和SetupCameraProperties全灰物体没被绘制检查ShaderTagId与LightMode是否一致粉红Shader编译错误看Console报错检查CBUFFER和include路径物体闪烁深度测试或提交时机问题检查RenderStateBlock和Submit位置我实测下来最容易出问题的是ShaderTagId和LightMode的匹配以及SetupCameraProperties的调用时机。这两个点确认无误基本就能跑通。6. 迁移与实战内置管线Shader改造为SRP的踩坑记录6.1 从UnityCG.cginc到Core.hlsl内置管线的Shader通常#include UnityCG.cgincSRP下要换成Core.hlslURP或对应的HLSL库。这个替换不是简单的改路径函数名和宏都有变化内置管线URP/SRP说明UnityObjectToClipPosTransformObjectToHClip名字变了功能一致_WorldSpaceCameraPosGetCameraPositionWS()从变量变成函数UNITY_LIGHTMODEL_AMBIENT无直接对应SRP下环境光通过SH或Lightmap处理sampler2D _MainTexTEXTURE2D(_BaseMap)纹理声明宏变化这些变化背后是SRP对跨平台兼容性的重新设计。TEXTURE2D、SAMPLER这些宏会根据目标平台展开成不同的声明方式比内置管线的sampler2D更灵活。迁移时如果只是改include路径而不改函数名编译会报一堆未定义标识符。6.2 光照处理的范式转变内置管线里前向渲染的光照变量是引擎自动填充的你直接用_WorldSpaceLightPos0、_LightColor0就行。SRP下光照数据需要通过CullingResults获取然后手动设置成全局变量或者通过LightData结构体传给Shader。URP封装了Lighting.hlsl提供了GetMainLight()、GetAdditionalLight()这些函数但底层逻辑还是从C#侧传过来的。这个转变意味着如果你自定义SRP光照完全由你控制。你可以决定支持几盏灯、用什么衰减模型、阴影怎么采样。自由度高了责任也大了。我建议刚开始不要自己写光照直接用URP的Lighting.hlsl等理解了光照数据的流转再考虑定制。6.3 阴影与多Pass的协调内置管线的阴影是引擎自动处理的你只要在Shader里写ShadowCasterPass就行。SRP下阴影贴图的渲染需要管线主动调用——URP在Render方法里会先渲染阴影贴图用ShadowCasterTag再渲染主画面。如果你自己写SRP必须手动安排这个顺序否则阴影不会出现。多Pass的协调也是类似。SRP下每个Pass的渲染时机由C#控制你可以决定先画深度、再画法线、最后画颜色。这种灵活性是做高级效果的基础但也要求你对渲染顺序有清晰的规划。我的经验是在纸上画出这一帧的Pass顺序图再动手写代码比直接写代码调试快得多。7. 几个绕不开的疑问与我的实测结论7.1 SRP和URP、HDRP到底是什么关系一句话SRP是机制URP和HDRP是基于这个机制实现的两套管线。URP是Unity官方维护的、面向广泛平台的SRP实现HDRP面向高端平台。你可以把URP看作SRP的一个官方范例它的源码是开放的读URP源码是学习SRP的最好途径。我建议的路径是先用极简SRP理解骨架再读URP的UniversalRenderPipeline.cs理解完整流程最后按需定制。7.2 自定义SRP值不值得对绝大多数项目不值得。URP已经覆盖了90%的需求自定义SRP的维护成本很高而且Unity版本升级时API可能变化。只有在以下情况才考虑需要极致的性能控制比如特定平台的定制优化、需要URP不支持的渲染特性、或者纯粹为了学习。我自己的项目用的是URP但我会写极简SRP来验证理解这个习惯帮我避开了很多知其然不知其所以然的坑。7.3 学习SRP需要什么前置知识图形学基础矩阵变换、光照模型、渲染管线概念是必须的C#的委托、结构体、内存管理也要熟悉。Shader方面能写顶点片元着色器是底线。如果这些还不熟建议先补基础否则学SRP会变成抄代码但不知道为什么。我见过太多人直接抄URP的代码改结果一遇到问题就卡死根源就是底层概念不牢。7.4 调试SRP的实用手段RenderDoc和Frame Debugger是两大神器。Frame Debugger能看每一帧的DrawCall、渲染目标、Shader属性定位物体为什么没画出来特别有效。RenderDoc能抓取GPU层面的详细状态适合分析性能瓶颈和渲染错误。另外在SRP代码里加Debug.Log输出关键状态比如剔除后的物体数量、当前渲染目标也是快速定位问题的笨办法但很管用。8. 把SRP装进脑子一张贯穿始终的心智模型学SRP最怕的是把它当成一堆零散API去记。我的建议是建立一个统一的心智模型SRP就是一条流水线C#是调度员Shader是工人CommandBuffer是工单ScriptableRenderContext是传送带。调度员C#每帧拿到订单相机列表决定这一帧要生产什么渲染目标、光照、阴影然后写工单CommandBuffer把工单放上传送带context工人Shader按照工单上的标签LightMode领取任务生产出像素。传送带最后统一启动Submit产品画面就出来了。这个模型能解释很多现象为什么改了C#变量Shader没反应工单还没送上传送带、为什么LightMode不匹配物体不显示工人没领到任务、为什么Submit之后不能再加指令传送带已经启动了。把这条流水线在脑子里跑通SRP的API就不再是死记硬背而是有逻辑的推导。我在实际项目里踩过的坑大多能归到这条流水线的某个环节要么是调度员指令写错了参数配置问题要么是工单格式不对CommandBuffer使用问题要么是工人和工单对不上Tag匹配问题。按这个模型去排查效率比盲目看代码高得多。最后分享一个我常用的调试习惯在SRP的每个关键节点SetupCameraProperties之后、Cull之后、DrawRenderers之后加一个带计数的Debug.Log跑一帧就能看出流程走到哪一步、每步处理了多少物体比盯着Frame Debugger猜要快。