Unity SRP底层原理与Shader开发实战:从内置管线到可编程渲染管线
1. 从一次渲染异常说起为什么要啃SRP的底层很多人第一次接触Unity Shader都是从Surface Shader或者直接抄一个片元着色器改改颜色开始的。我当初也一样觉得能写出一个带边缘光、能接受阴影的Shader就算入门了。直到有一次项目里需要做一套自定义的卡通渲染角色要在不同光照环境下保持稳定的色调同时还要支持多光源和阴影投射。我按照老路子写了一个Surface Shader结果发现光照衰减怎么调都不对阴影边缘总是有奇怪的硬切多光源叠加时颜色直接崩掉。折腾了两天最后一位做引擎方向的朋友看了一眼说“你还在用内置管线那套东西该看看SRP了。”这句话点醒了我。SRPScriptable Render Pipeline可编程渲染管线不是一个简单的“新功能”它是Unity把渲染管线的控制权从引擎内部交到开发者手里的一次架构级重构。你写的Shader能不能跑、怎么跑、跑的时候拿到什么数据全都跟SRP的底层机制绑在一起。不理解SRP你写Shader就是在盲人摸象——能跑但不知道为什么能跑出了问题也不知道从哪查。这篇文章适合两类人一类是已经能写简单Shader但一遇到光照、阴影、多Pass就发懵的开发者另一类是想从内置管线迁移到URP或者HDRP但被各种“管线不兼容”搞得头大的朋友。我会从SRP的核心设计思路讲起把底层原理拆开再结合我实际踩过的坑给出可以直接参考的排查方法和代码片段。不堆术语尽量说人话。2. SRP到底改了什么从内置管线到可编程管线2.1 内置管线的“黑盒”困境Unity的内置渲染管线Built-in Render Pipeline是一套写死的流程。你写一个Shader引擎在背后帮你做了一大堆事情剔除、排序、设置渲染目标、调用Draw Call、处理光照和阴影。你只需要在Shader里声明Tags、写Pass、调用Lighting相关的宏剩下的引擎全包了。这套机制在早期很好用上手快但问题也很明显。比如你想改一下阴影的渲染方式或者想在渲染不透明物体之前插入一个自定义的深度预Pass内置管线基本不给你这个机会。你只能通过Camera的OnPreRender、OnRenderImage这些回调去“蹭”一下但能做的事情非常有限。更麻烦的是内置管线的光照处理是固定的前向渲染下每个Pass处理一个光源光照衰减和阴影采样都在引擎内部完成你拿不到中间数据也没法替换算法。我遇到过最典型的问题就是光照衰减。内置管线里点光源和聚光灯的衰减是通过_LightTexture0这张查找纹理来采样的你没法改它的衰减曲线。但在卡通渲染里我们希望光照有一个明确的“明暗交界线”而不是平滑衰减。内置管线做不到只能靠后处理去硬凑效果很假。2.2 SRP的核心思路把渲染流程交给你SRP的设计哲学很简单引擎只提供基础能力渲染流程由你用C#脚本自己组织。Unity把渲染相关的底层API暴露出来比如ScriptableRenderContext、CommandBuffer、CullingResults你通过这些API来告诉引擎“先做什么、再做什么、用什么参数做”。具体来说SRP把渲染管线拆成了几个核心步骤剔除Culling通过context.Cull()拿到可见物体列表你可以自己决定哪些物体参与渲染。绘制设置Setup设置渲染目标、清屏颜色、深度测试等。绘制调用Draw通过context.DrawRenderers()或者cmd.DrawRenderer()来实际渲染物体。提交Submit最后context.Submit()把命令提交给GPU。这套流程听起来简单但真正写起来你会发现光照、阴影、多Pass这些在内置管线里“免费”的东西现在全都要自己实现。URP和HDRP就是Unity官方基于SRP写好的两套完整实现你可以直接用也可以参考它们的源码来写自己的管线。2.3 SRP Batcher为什么你的Shader突然变慢了SRP引入了一个新的合批机制叫SRP Batcher。它的原理和内置管线的动态合批、静态合批完全不同。内置合批是把多个物体的顶点数据合并到一个大Mesh里减少Draw Call数量。而SRP Batcher不合并Mesh它合并的是材质属性。具体来说SRP Batcher要求你的Shader使用统一的CBUFFER常量缓冲区布局。如果两个Shader的CBUFFER结构一致引擎就可以在GPU端快速切换材质属性而不需要重新绑定整个Shader。这听起来很美好但有一个坑如果你的Shader没有正确声明CBUFFERSRP Batcher就不会生效Draw Call会暴涨。我当初迁移到URP的时候发现场景里几百个物体Draw Call直接飙到上千。查了半天才发现我自定义的Shader里把材质属性散落在各个CBUFFER里没有统一到UnityPerMaterial这个CBUFFER下。改完之后Draw Call直接降到几十。这个坑非常隐蔽因为Shader能正常渲染只是性能差你不看Frame Debugger根本发现不了。提示在URP下所有自定义Shader都应该把材质属性放在CBUFFER_START(UnityPerMaterial)和CBUFFER_END之间并且确保所有使用相同Shader的材质属性布局一致。这是SRP Batcher生效的前提。3. 拆开SRP的骨架核心类与数据流3.1 ScriptableRenderContext命令的“传令官”ScriptableRenderContext是SRP里最核心的类你可以把它理解成CPU和GPU之间的“传令官”。你通过它来发送各种渲染命令但它本身不执行任何渲染只是把命令记录下来最后统一提交。它的常用方法包括Cull()执行剔除返回CullingResults。DrawRenderers()绘制一组渲染器。ExecuteCommandBuffer()执行一个CommandBuffer。Submit()提交所有命令。这里有一个很容易踩的坑ScriptableRenderContext的方法不会立即执行。比如你调用DrawRenderers()它只是把绘制命令记录下来真正执行是在Submit()的时候。这意味着你不能在两次DrawRenderers()之间去修改渲染目标除非你用CommandBuffer来插入命令。我当初写自定义管线的时候想在渲染不透明物体之后、透明物体之前插入一个深度图生成Pass。我直接在两次DrawRenderers()之间改了RenderTexture结果发现深度图根本没生成。后来才明白必须用cmd.SetRenderTarget()把命令插入到CommandBuffer里然后通过context.ExecuteCommandBuffer()来执行。3.2 CommandBuffer渲染命令的“集装箱”CommandBuffer是SRP里另一个核心类它是一系列渲染命令的集合。你可以往里面塞各种命令比如设置渲染目标、清屏、绘制Mesh、设置Shader参数等。然后通过context.ExecuteCommandBuffer()来执行。CommandBuffer的好处是灵活。你可以在渲染流程的任意位置插入自定义命令而不需要修改管线的整体结构。比如你想在渲染阴影之前先渲染一张自定义的深度图就可以在阴影Pass之前执行一个CommandBuffer。但CommandBuffer也有坑。最常见的问题是渲染目标切换。如果你在CommandBuffer里设置了渲染目标但没有正确恢复后续的渲染就会出错。我遇到过一种情况在生成深度图之后忘记把渲染目标切回相机目标结果整个场景渲染到了深度图上屏幕一片黑。排查了半天才发现是cmd.SetRenderTarget()没有配对使用。注意每次cmd.SetRenderTarget()之后都要确保在合适的时机切回原来的渲染目标。可以用cmd.SetRenderTarget(cameraTarget)来恢复或者用context.SetupCameraProperties()来重置相机相关的渲染状态。3.3 CullingResults可见物体的“花名册”CullingResults是剔除阶段的结果里面包含了所有可见的物体、光源、反射探针等信息。你可以通过它来获取可见光源列表、可见物体列表然后决定怎么渲染。在自定义管线里光照处理完全依赖CullingResults。你需要自己遍历可见光源为每个光源设置Shader参数然后调用DrawRenderers()来渲染。URP的前向渲染就是这么做的它把可见光源分成主光源和附加光源主光源在一个Pass里渲染附加光源在另一个Pass里渲染。这里有一个关键点CullingResults里的光源顺序是不确定的。你不能假设第一个光源就是主光源。URP通过sortingSettings和visibleLights里的lightType来判断哪个是主光源。如果你自己写管线也需要做类似的判断否则光照会随机变化。3.4 数据流全景从C#到Shader把上面几个类串起来SRP的数据流大致是这样的C#端调用context.Cull()拿到CullingResults。根据CullingResults里的光源信息设置Shader的全局参数比如光源方向、颜色、衰减。通过CommandBuffer设置渲染目标、清屏、设置视口。调用context.DrawRenderers()传入DrawingSettings和FilteringSettings绘制可见物体。在Shader端通过UnityPerMaterialCBUFFER和全局CBUFFER拿到C#端设置的数据进行顶点变换和片元着色。最后context.Submit()提交所有命令。这个流程里C#端和Shader端的参数传递是最容易出问题的地方。比如你在C#端用Shader.SetGlobalVector(_MainLightDirection, dir)设置了光源方向但Shader里声明的是float4 _MainLightDirection看起来没问题但如果你的Shader没有正确声明CBUFFER这个参数可能传不进去。特别是在SRP Batcher开启的情况下全局参数和材质参数的传递机制是不一样的。4. 手写一个极简SRP管线从零到一4.1 创建管线资产与Renderer要写一个自定义SRP管线首先需要两个C#类一个继承RenderPipelineAsset一个继承RenderPipeline。RenderPipelineAsset是管线资产的基类你可以在Project窗口里创建它然后赋值给GraphicsSettings.renderPipelineAsset。using UnityEngine; using UnityEngine.Rendering; [CreateAssetMenu(menuName Custom Pipeline/My Pipeline Asset)] public class MyPipelineAsset : RenderPipelineAsset { protected override RenderPipeline CreatePipeline() { return new MyPipeline(); } }MyPipeline类里实现具体的渲染逻辑。核心方法是Render()它接收ScriptableRenderContext和Camera[]你在这里组织整个渲染流程。using UnityEngine; using UnityEngine.Rendering; public class MyPipeline : RenderPipeline { private CommandBuffer _cmd; protected override void Render(ScriptableRenderContext context, Camera[] cameras) { _cmd new CommandBuffer { name MyPipeline }; foreach (var camera in cameras) { RenderCamera(context, camera); } context.Submit(); } private void RenderCamera(ScriptableRenderContext context, Camera camera) { // 剔除 if (!camera.TryGetCullingParameters(out var cullingParams)) return; var cullingResults context.Cull(ref cullingParams); // 设置相机属性 context.SetupCameraProperties(camera); // 清屏 _cmd.ClearRenderTarget(true, true, Color.clear); context.ExecuteCommandBuffer(_cmd); _cmd.Clear(); // 绘制物体 var drawingSettings new DrawingSettings( new ShaderTagId(SRPDefaultUnlit), new SortingSettings(camera) ); var filteringSettings new FilteringSettings(RenderQueueRange.all); context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings); // 绘制天空盒 context.DrawSkybox(camera); } }这段代码就是一个最简SRP管线。它做的事情和内置管线的最简前向渲染差不多剔除、清屏、绘制不透明物体、绘制天空盒。但它没有光照、没有阴影、没有后处理。要支持这些需要继续扩展。4.2 光照处理的实现思路在SRP里处理光照核心是把光源信息从C#端传到Shader端。URP的做法是从CullingResults.visibleLights里找到主光源通常是方向光。把主光源的方向、颜色、强度设置到Shader的全局参数里。在Shader里用这些参数计算光照。private void SetupMainLight(CullingResults cullingResults) { var visibleLights cullingResults.visibleLights; for (int i 0; i visibleLights.Length; i) { var light visibleLights[i]; if (light.lightType LightType.Directional) { Vector4 dir -light.localToWorldMatrix.GetColumn(2); _cmd.SetGlobalVector(_MainLightDirection, dir); _cmd.SetGlobalColor(_MainLightColor, light.finalColor); break; } } }Shader端对应的声明float4 _MainLightDirection; half4 _MainLightColor; half3 CalculateMainLight(float3 normal) { half3 lightDir normalize(_MainLightDirection.xyz); half NdotL saturate(dot(normal, lightDir)); return _MainLightColor.rgb * NdotL; }这里有一个细节light.localToWorldMatrix.GetColumn(2)拿到的是光源的“前向”方向但光照计算需要的是从表面指向光源的方向所以取负号。这个细节在内置管线里是引擎帮你处理的SRP里需要你自己算。4.3 阴影的实现思路阴影在SRP里是最复杂的部分之一。你需要创建一个阴影贴图Shadow Map。从光源视角渲染场景的深度。在相机渲染时把阴影贴图和光源的视图投影矩阵传给Shader。在Shader里采样阴影贴图计算阴影衰减。URP的阴影实现涉及多个Pass和多个RenderTexture代码量很大。但核心思路就是上面四步。我当初自己写阴影的时候最大的坑是阴影贴图的精度和偏移。如果阴影贴图分辨率太低阴影边缘会有锯齿如果偏移太小会有阴影痤疮Shadow Acne如果偏移太大阴影会脱离物体Peter Panning。解决阴影痤疮的常用方法是法线偏移在渲染阴影贴图时把顶点沿着法线方向偏移一点。这个偏移量需要根据光源角度和阴影贴图分辨率来调整没有万能值。我一般会先设一个较小的值比如0.01然后根据实际效果微调。提示在URP里阴影相关的参数可以在Light组件和URP Asset里调整。如果你自己写管线建议参考URP的ShadowCasterPass和MainLightShadowCasterPass的实现里面有很多细节处理。5. 常见问题与排查技巧实录5.1 Shader在SRP下不生效的几种原因迁移到SRP之后最常见的问题就是“Shader突然不工作了”。我整理了几种典型情况问题现象可能原因排查方法物体渲染成粉色Shader编译错误或SubShader不兼容打开Shader Inspector看有没有报错物体完全不显示Pass的LightMode标签不对检查Tags { LightMode SRPDefaultUnlit }光照计算错误CBUFFER声明不正确检查UnityPerMaterial和全局CBUFFERDraw Call异常高SRP Batcher未生效在Frame Debugger里看是否显示“SRP Batch”阴影不显示阴影Pass缺失或参数错误检查是否有ShadowCasterPass其中LightMode标签是最容易忽略的。内置管线里不写LightMode的Pass默认会被渲染但在SRP里只有LightMode匹配的Pass才会被DrawRenderers()调用。URP常用的LightMode包括SRPDefaultUnlit、UniversalForward、ShadowCaster、DepthOnly等。如果你自定义管线需要自己定义LightMode标签并在DrawingSettings里指定。5.2 SRP Batcher失效的排查步骤SRP Batcher失效是性能问题的重灾区。排查步骤我一般是这样打开Frame Debugger看Draw Call列表里有没有“SRP Batch”标记。如果没有检查Shader的Inspector看“SRP Batcher”那一栏是不是“Not compatible”。如果是“Not compatible”检查Shader里是否有CBUFFER_START(UnityPerMaterial)并且所有材质属性都在这个CBUFFER里。检查是否有多个Pass使用了不同的CBUFFER布局。检查是否有Shader变体导致CBUFFER布局不一致。我遇到过一次SRP Batcher显示“Not compatible”查了半天发现是Shader里有一个float4 _Color声明在了CBUFFER外面。把它移进去之后SRP Batcher立刻生效Draw Call从800多降到60多。5.3 多光源下的光照叠加问题在自定义管线里处理多光源最容易出现的问题是光照叠加错误。比如两个点光源照在同一个物体上颜色不是叠加而是覆盖。这通常是因为你在Shader里用了return而不是或者没有正确设置混合模式。URP的前向渲染里主光源和附加光源是分开Pass渲染的。主光源Pass用Blend One Zero附加光源Pass用Blend One One。这样多个光源的颜色会累加。如果你自己写管线需要确保附加光源的Pass设置了正确的混合模式。另外附加光源的数量是有限制的。URP默认最多支持8个附加光源可以在URP Asset里调整。如果场景里光源太多超出的部分会被忽略。这个限制在内置管线里也有但SRP里你可以自己控制。5.4 深度图与法线图的生成时机如果你需要做后处理或者屏幕空间效果深度图和法线图是必不可少的。在SRP里深度图的生成时机很关键。URP的做法是在渲染不透明物体之前先渲染一张深度图Depth Prepass然后在渲染不透明物体时复用这张深度图。但深度图生成有一个坑如果你的Shader没有DepthOnlyPass深度图里就不会有那个物体的深度。这意味着后处理里读到的深度是不完整的会导致边缘出现奇怪的描边或者遮挡错误。我当初做描边效果的时候就因为这个原因角色边缘的描边断断续续。后来给所有自定义Shader都加上了DepthOnlyPass问题才解决。注意在URP里DepthOnlyPass的LightMode标签是DepthOnlyDepthNormalsPass的标签是DepthNormals。如果你需要法线图还需要额外写一个DepthNormalsPass。6. 从理解到落地我的SRP学习路径建议回头看我学SRP的过程大概分了三个阶段。第一个阶段是“能用”就是照着URP的文档把内置管线的Shader改成URP能用的版本能跑起来就行。这个阶段最重要的是理解LightMode标签和CBUFFER的作用知道为什么Shader会变粉、为什么光照不对。第二个阶段是“能改”就是能根据自己的需求修改URP的渲染流程。比如调整阴影的分辨率、增加一个自定义的渲染Pass、修改光照衰减的计算方式。这个阶段需要读URP的源码理解ScriptableRenderer、ScriptableRenderPass这些类的协作方式。我建议从UniversalRenderPipeline.RenderSingleCamera()这个方法开始读它是整个URP渲染流程的入口。第三个阶段是“能写”就是从零写一个自己的SRP管线。这个阶段不需要写得多完整哪怕只是一个最简的前向渲染只要能跑通你对SRP的理解就会上一个台阶。我当初写了一个只支持方向光、没有阴影、没有后处理的管线代码不到300行但写完之后再看URP的源码很多之前看不懂的地方突然就通了。如果你现在还在第一个阶段我的建议是不要急着写自定义管线。先把URP的Shader写熟理解SRP Batcher、LightMode、CBUFFER这些基础概念。然后找一个周末照着URP的源码把RenderSingleCamera()的流程走一遍在关键节点打日志看看每个阶段的数据是什么。这个过程比看十篇教程都有用。最后分享一个我常用的调试技巧在Shader里用return half4(1, 0, 0, 1)来输出纯红色快速判断Shader是否被执行。如果物体变红了说明Shader在跑问题出在光照计算或者参数传递上如果没变红说明Shader根本没被调用问题出在LightMode标签或者管线配置上。这个技巧帮我省了很多排查时间。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →