1. 从一次渲染异常说起为什么要啃SRP底层前阵子帮一个做二次元风格项目的朋友排查问题他们用Unity的URP管线做角色渲染角色脸部在特定光照角度下会出现一条明显的硬边换了好几种Shader写法都压不下去。最后定位到问题出在自定义的Lighting函数里他们直接照搬了网上某份URP卡通渲染模板但那份模板是基于旧版本SRP接口写的光照方向的计算在管线内部已经换了一套坐标系约定导致主光方向和附加光方向在边缘处对不齐。这件事让我意识到一个很普遍的现象很多人写Unity Shader能改颜色、能加描边、能调Ramp但一旦涉及管线层面的东西——比如光照怎么传进来、阴影图怎么采样、多Pass怎么组织——就完全靠抄模板出了问题只能靠试。SRPScriptable Render Pipeline可编程渲染管线就是这套管线层面的核心。它不是某个具体的Shader写法而是Unity把渲染管线的组织权从引擎内部开放给C#层的一套架构。URP和HDRP都是基于SRP构建的具体管线实现。你写的每一个Shader最终都要在这套架构下被组织、被调用、被提交给GPU。不理解SRP你写的Shader就永远停留在调参层面遇到跨管线、跨版本、跨平台的问题就只能干瞪眼。这篇内容适合两类人一类是已经能写基础Unity Shader、但想搞清楚我写的代码到底在什么时候被谁调用的开发者另一类是做二次元渲染、后处理、自定义光照这类偏效果向工作需要深度定制管线行为的人。我会从SRP的整体架构讲起把渲染管线的组织逻辑、Shader在其中的位置、光照与阴影的数据流、以及实际定制时最容易踩的坑一层层拆开。全程用Unity的语境来讲但底层思路对理解任何现代渲染管线都有参考价值。2. SRP管线的整体架构与设计思路2.1 为什么Unity要搞SRP从内置管线到可编程管线在SRP出现之前Unity用的是内置渲染管线Built-in Render Pipeline。那套管线是写死在C引擎层的你能控制的只有Shader里那几个固定的Pass类型——ForwardBase、ForwardAdd、ShadowCaster、Deferred等等。每个Pass什么时候被调用、按什么顺序调用、光照数据怎么组织全是引擎说了算。你想加一个自定义的渲染阶段比如在所有不透明物体渲染完之后、透明物体之前插入一个全屏的深度处理内置管线里几乎做不到只能靠OnRenderImage这种后处理钩子硬凑。这套模式的问题在于移动端要的是轻量、PC端要的是效果、主机端要的是极致性能一套写死的管线没法同时满足。于是Unity在2018版本推出了SRP把管线的组织逻辑从C层搬到了C#层。核心思路是引擎只负责最底层的图形API调用比如DrawCall提交、RenderTarget绑定而什么时候渲染什么、按什么顺序渲染、用什么参数渲染这些决策全部交给你用C#代码来控制。这个设计带来的直接好处是URP可以针对移动端做极致裁剪HDRP可以堆满各种高级特性而两者共享同一套SRP底层框架。对开发者来说你既可以拿来即用也可以在URP基础上写自己的RenderFeature来插入自定义阶段。这就是SRP最核心的价值把管线的控制权还给开发者。2.2 SRP的核心组成RenderPipeline、Context、CameraSRP的架构可以拆成三个核心角色。第一个是RenderPipeline它是一个抽象基类你自定义的管线要继承它并实现Render方法。这个Render方法就是整条管线的入口每一帧、每个相机都会调用一次。URP里的UniversalRenderPipeline就是继承自它。第二个是ScriptableRenderContext这是引擎和你的C#代码之间的桥梁。你不能直接调用图形API而是通过Context来提交各种命令——比如context.DrawRenderers提交一批物体、context.ExecuteCommandBuffer执行一段命令缓冲、context.Submit把这一帧的所有命令真正提交给GPU。理解Context的关键是它是个命令队列你往里面塞命令最后统一提交而不是塞一条执行一条。第三个是相机。SRP里每个相机都会触发一次Render调用你需要自己决定这个相机要渲染什么。比如主相机要渲染不透明物体加透明物体阴影相机只渲染阴影投射物反射探针相机渲染到CubeMap。这些决策全在C#层做。public class MyPipeline : RenderPipeline { protected override void Render(ScriptableRenderContext context, Camera[] cameras) { foreach (var camera in cameras) { // 1. 设置相机参数 context.SetupCameraProperties(camera); // 2. 剔除 ScriptableCullingParameters cullingParams; CullingResults cullingResults ...; // 3. 绘制 // 4. 提交 context.Submit(); } } }上面这段伪代码就是SRP管线的最小骨架。真实管线里剔除、排序、光照、阴影、后处理都在这个框架里展开。URP的Render方法有上千行但骨架就是这么简单。2.3 URP和HDRP的关系同一套SRP不同的取舍很多人搞不清URP、HDRP和SRP的关系。简单说SRP是框架URP和HDRP是基于这个框架的两套具体实现。URP走的是前向渲染为主、轻量高效的路线适合移动端和中等配置的PCHDRP走的是延迟渲染、物理光照、光线追踪的路线适合高端PC和主机。两者共享SRP的底层接口但内部实现差异很大。比如URP的Shader库叫UniversalHDRP的叫HDRP两者的光照函数、阴影采样、材质属性都不一样。这就是为什么你从URP项目里拷一个Shader到HDRP项目里大概率编译不过——因为引用的库文件根本不存在。理解这层关系很重要因为它决定了你写Shader时的依赖边界。如果你写的是纯自定义的、不依赖管线光照的Shader比如一个纯色UI、一个自发光粒子那它在URP和HDRP里都能跑。但只要你用了Lighting.hlsl里的光照函数就绑定了具体管线。这也是为什么做二次元渲染时很多人选择自己写一套完整的光照计算而不是调用管线的光照函数——为了跨管线兼容。3. Shader在SRP中的位置与数据流3.1 Shader的编译与变体SRP下的新规则在内置管线里Shader的变体主要靠#pragma multi_compile和#pragma shader_feature来控制。SRP延续了这套机制但增加了一个关键概念RenderPipeline关键字。Unity会自动为当前激活的管线定义一个宏比如URP下会定义UNIVERSAL_RENDER_PIPELINEHDRP下定义HDRP_RENDER_PIPELINE。你可以用这个宏来做管线分支。#if defined(UNIVERSAL_RENDER_PIPELINE) // URP专属逻辑 #elif defined(HDRP_RENDER_PIPELINE) // HDRP专属逻辑 #else // 内置管线逻辑 #endif这个机制让同一个Shader文件可以适配多条管线但代价是变体数量爆炸。我见过一个项目Shader变体数量超过两万个打包时间从十分钟涨到两小时。所以SRP下管理变体是个必修课。核心原则是能用shader_feature就别用multi_compile能靠材质属性分支的就别靠关键字分支。URP还提供了ShaderVariantCollection和IPreprocessShaders接口来做变体剥离这个后面细讲。另一个SRP特有的点是Shader的Pass组织。内置管线里Pass类型是固定的SRP里你可以自定义Pass的LightMode标签。比如URP里除了标准的UniversalForward、ShadowCaster、DepthOnly你还可以定义MyCustomPass然后在RenderFeature里通过ShaderTagId来指定渲染哪些Pass。这是SRP灵活性的直接体现。3.2 光照数据的传递从C#到Shader的完整链路这是SRP里最容易让人迷糊的部分。在内置管线里光照数据是引擎自动塞进Shader的你只要用_WorldSpaceLightPos0、_LightColor0这些内置变量就行。SRP里这些变量还在但填充逻辑变了——是URP的C#代码主动设置的。以主光为例URP在UniversalRenderPipeline.RenderSingleCamera里会调用InitializeLightConstants把主光的方向、颜色、强度、阴影参数打包成LightData结构然后通过CommandBuffer.SetGlobalVector之类的接口塞给Shader。Shader端通过Lighting.hlsl里的GetMainLight()函数读取。这个函数返回一个Light结构体包含方向、颜色、距离衰减、阴影衰减等。Light mainLight GetMainLight(); float3 lightDir mainLight.direction; float3 lightColor mainLight.color; float shadowAtten mainLight.shadowAttenuation;附加光Additional Light走的是另一条路。URP默认支持每物体最多8个附加光可配置这些光的数据被打包成数组通过_AdditionalLightsPosition、_AdditionalLightsColor等全局数组传给Shader。Shader端用GetAdditionalLightsCount()和GetAdditionalLight(i, position)来遍历。这里有个关键细节附加光的索引和实际光源的对应关系是URP在C#层排序后决定的。排序规则是按光源对物体的影响程度距离、强度、范围动态调整。这意味着同一个物体在不同帧、不同位置接收到的附加光顺序可能不同。如果你在Shader里假设第0个附加光一定是某个特定光源那就会出问题。这个坑我在做角色多光源补光时踩过角色移动时补光会突然跳变就是因为附加光排序变了。3.3 阴影的采样ShadowMap、Cascade与屏幕空间阴影SRP下的阴影系统比内置管线复杂得多因为URP支持多种阴影模式主光阴影、附加光阴影、屏幕空间阴影Screen Space Shadows。每种模式的采样方式不同。主光阴影走的是Cascade Shadow Map。URP在C#层计算好每个Cascade的投影矩阵和分割距离通过_MainLightShadowmapTexture和_MainLightWorldToShadow数组传给Shader。Shader端用TransformWorldToShadowCoord把世界坐标转到阴影空间再采样。Cascade的作用是让近处阴影精细、远处阴影粗糙平衡质量和性能。float4 shadowCoord TransformWorldToShadowCoord(worldPos); float shadow MainLightRealtimeShadow(shadowCoord);附加光阴影默认是关闭的需要在URP Asset里开启。开启后每个附加光都有自己的阴影图采样方式和主光类似但用的是_AdditionalLightsShadowmapTexture。这里要注意附加光阴影的性能开销很大移动端基本别想。屏幕空间阴影是URP较新版本引入的特性它把屏幕空间深度图当作阴影源用于处理自阴影和接触阴影。这个模式下阴影采样走的是屏幕UV而不是世界坐标投影。如果你在Shader里混用了两种采样方式阴影就会错位。提示SRP下阴影采样最容易出错的地方是坐标系转换。主光阴影用的是世界空间到阴影空间的矩阵屏幕空间阴影用的是屏幕UV附加光阴影又是另一套。写自定义阴影逻辑前先确认当前管线用的是哪种模式。4. 自定义SRP管线的实操过程4.1 从零搭建一个最小SRP管线要真正理解SRP最好的办法是自己搭一个最小管线。下面这套流程是我实际搭过并跑通的代码量不大但能让你看清整条链路。第一步创建RenderPipelineAsset。这是个ScriptableObject负责在编辑器里配置管线参数并创建管线实例。[CreateAssetMenu(menuName MyPipeline/MyPipelineAsset)] public class MyPipelineAsset : RenderPipelineAsset { protected override RenderPipeline CreatePipeline() { return new MyPipeline(); } }第二步实现RenderPipeline。核心是Render方法里面要处理相机设置、剔除、绘制、提交。public class MyPipeline : RenderPipeline { private CommandBuffer _cmd new CommandBuffer { name MyPipeline }; protected override void Render(ScriptableRenderContext context, Camera[] cameras) { foreach (var camera in cameras) { RenderCamera(context, camera); } } private void RenderCamera(ScriptableRenderContext context, Camera camera) { // 1. 设置相机属性 context.SetupCameraProperties(camera); // 2. 清除 _cmd.ClearRenderTarget(true, true, Color.clear); context.ExecuteCommandBuffer(_cmd); _cmd.Clear(); // 3. 剔除 if (!camera.TryGetCullingParameters(out var cullingParams)) return; var cullingResults context.Cull(ref cullingParams); // 4. 绘制不透明物体 var drawSettings new DrawingSettings( new ShaderTagId(SRPDefaultUnlit), new SortingSettings(camera)); var filterSettings new FilteringSettings(RenderQueueRange.opaque); context.DrawRenderers(cullingResults, ref drawSettings, ref filterSettings); // 5. 绘制天空盒 context.DrawSkybox(camera); // 6. 绘制透明物体 filterSettings.renderQueueRange RenderQueueRange.transparent; context.DrawRenderers(cullingResults, ref drawSettings, ref filterSettings); // 7. 提交 context.Submit(); } }这段代码跑起来后你会看到一个没有光照、没有阴影、没有后处理的场景。物体用的是SRPDefaultUnlit这个Pass也就是Shader里LightMode标签为SRPDefaultUnlit的Pass。这就是SRP的起点——一切都要你自己加。4.2 加入光照与阴影让管线活起来最小管线跑通后下一步是加光照。SRP本身不提供光照光照逻辑要你自己写。URP的做法是在C#层设置光照常量在Shader层用光照函数计算。我们简化一下手动设置主光。// 在RenderCamera里绘制之前 _cmd.SetGlobalVector(_MainLightDirection, -light.transform.forward); _cmd.SetGlobalColor(_MainLightColor, light.color * light.intensity); context.ExecuteCommandBuffer(_cmd); _cmd.Clear();Shader端float3 lightDir normalize(_MainLightDirection.xyz); float3 lightColor _MainLightColor.rgb; float ndotl saturate(dot(normalWS, lightDir)); float3 diffuse albedo * lightColor * ndotl;阴影要复杂一些。你需要先渲染一张阴影图再在绘制物体时采样。渲染阴影图的流程是创建一个RenderTexture设置阴影相机的投影矩阵用ShadowCaster这个Pass渲染所有投射阴影的物体。// 创建阴影图 int shadowMapId Shader.PropertyToID(_MyShadowMap); _cmd.GetTemporaryRT(shadowMapId, 1024, 1024, 16, FilterMode.Bilinear, RenderTextureFormat.Shadowmap); _cmd.SetRenderTarget(shadowMapId, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store); _cmd.ClearRenderTarget(true, false, Color.clear); // 设置阴影相机矩阵 _cmd.SetViewProjectionMatrices(shadowView, shadowProj); context.ExecuteCommandBuffer(_cmd); _cmd.Clear(); // 渲染阴影投射物 var shadowDrawSettings new DrawingSettings(new ShaderTagId(ShadowCaster), new SortingSettings()); var shadowFilter new FilteringSettings(RenderQueueRange.all); context.DrawRenderers(cullingResults, ref shadowDrawSettings, ref shadowFilter);然后在主渲染时把阴影图传给Shader用世界坐标投影采样。这套流程和URP内部做的事本质一样只是URP封装得更完善。自己搭一遍你就明白URP那些_MainLightShadowmapTexture是怎么来的了。4.3 RenderFeature在URP里插入自定义阶段实际项目里你很少从零搭管线更多是在URP基础上用RenderFeature插入自定义阶段。RenderFeature是URP提供的扩展点允许你在管线的特定时机执行自定义的CommandBuffer。一个典型的RenderFeature包含Create、AddRenderPasses、Execute三个部分。AddRenderPasses决定这个Feature在哪些相机、哪些时机被加入Execute是实际执行的逻辑。public class MyOutlineFeature : ScriptableRendererFeature { class MyOutlinePass : ScriptableRenderPass { public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { var cmd CommandBufferPool.Get(MyOutline); // 自定义绘制逻辑 context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } } private MyOutlinePass _pass; public override void Create() { _pass new MyOutlinePass { renderPassEvent RenderPassEvent.AfterRenderingOpaques }; } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { renderer.EnqueuePass(_pass); } }renderPassEvent是关键参数它决定你的Pass插入在管线的哪个位置。常用的有BeforeRenderingOpaques、AfterRenderingOpaques、BeforeRenderingTransparents、AfterRenderingTransparents、AfterRenderingPostProcessing。做描边通常插在AfterRenderingOpaques做后处理插在AfterRenderingPostProcessing。注意RenderFeature的Execute里不要直接调用context.Submit()URP会在合适的时机统一提交。你自己Submit会导致命令顺序错乱画面闪烁。5. 常见问题与排查技巧实录5.1 Shader在URP下报错Lighting.hlsl not found这是从内置管线迁移到URP时最常见的问题。原因是内置管线的Shader引用了UnityCG.cginc而URP用的是Core.hlsl和Lighting.hlsl。解决方法是在Shader里改用URP的include路径#include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl同时把内置管线的宏替换成URP的。比如UNITY_MATRIX_MVP换成TransformObjectToHClip_WorldSpaceCameraPos换成GetCameraPositionWS()。这些替换不是简单的改名背后的坐标系约定可能不同要逐个确认。5.2 自定义光照在物体边缘出现硬边或跳变前面提到的二次元项目问题就是这个。根本原因是主光和附加光的方向计算用了不同的坐标系。URP里GetMainLight().direction返回的是世界空间方向而附加光的方向需要你自己用lightPos - worldPos算。如果两者混用边缘处就会不连续。解决方法是在Shader里统一光照方向的计算方式。要么全部用世界空间要么全部用切线空间。做卡通渲染时我通常把主光和附加光都转到同一个空间一般是世界空间再统一做Ramp采样。这样边缘过渡就平滑了。5.3 阴影在移动端出现锯齿或闪烁移动端阴影问题通常有三个来源阴影图分辨率太低、Cascade分割不合理、阴影偏移Bias设置不当。阴影图分辨率在URP Asset里配置移动端一般用1024或2048。Cascade数量移动端建议2个PC端4个。Bias分两种Depth Bias和Normal Bias前者解决自阴影后者解决表面偏移。移动端因为精度低Bias要适当调大但调太大会导致阴影脱离物体俗称阴影飘了。我的一般做法是先关掉所有Bias看阴影问题出在哪再逐步加Bias。Depth Bias从1.0开始试Normal Bias从0.5开始试。同时开启Shadow Cascade的Soft Shadows能明显改善锯齿。5.4 变体爆炸导致打包缓慢前面提过SRP下变体管理是必修课。排查变体数量的方法是打开Project Settings Graphics Shader Loading或者用ShaderUtil接口统计。剥离变体的核心手段有三个一是用shader_feature替代multi_compile二是用IPreprocessShaders接口在打包时剔除未使用的变体三是用ShaderVariantCollection记录实际用到的变体。class MyVariantStripper : IPreprocessShaders { public int callbackOrder 0; public void OnProcessShader(Shader shader, ShaderSnippetData snippet, IListShaderCompilerData data) { for (int i data.Count - 1; i 0; i--) { // 根据关键字剔除 if (data[i].shaderKeywordSet.IsEnabled(new ShaderKeyword(_MY_UNUSED_KEYWORD))) data.RemoveAt(i); } } }这个接口在打包时被调用你可以根据项目实际需求剔除变体。我见过一个项目靠这个把变体从两万降到三千打包时间从两小时降到二十分钟。5.5 常见问题速查表问题现象可能原因排查方向Shader编译报错找不到库引用了内置管线路径改用URP的Packages路径光照边缘硬边主光/附加光坐标系不一致统一转到世界空间计算阴影锯齿严重阴影图分辨率低或Bias不当调分辨率、Cascade、Bias画面闪烁RenderFeature里重复Submit移除自定义Submit打包缓慢变体数量爆炸用IPreprocessShaders剥离附加光跳变光源排序动态变化不要假设固定索引透明物体排序错乱渲染队列设置不当检查材质RenderQueue后处理失效RenderPassEvent时机不对调整到AfterRenderingPostProcessing6. 从SRP底层看二次元渲染的定制空间做二次元渲染的人最终都会走到定制管线这一步。因为标准的URP光照模型是物理向的而二次元要的是非物理的、可控的、风格化的光照。SRP给了你这种定制的可能。具体来说二次元渲染通常要改三个地方。第一是光照计算把物理的NdotL换成Ramp采样把连续的光照变成阶梯状。第二是阴影把实时阴影换成基于SDF的面部阴影或者用Ramp控制的硬边阴影。第三是描边用RenderFeature在物体渲染后插入一个背面外扩的描边Pass。这些定制都依赖对SRP数据流的理解。比如Ramp采样需要知道光照方向在哪个空间SDF阴影需要知道面部法线怎么传描边需要知道深度图什么时候可用。不理解SRP这些定制就只能靠抄出了问题没法调。我自己做二次元项目时习惯把光照计算全部重写不调用URP的Lighting.hlsl。主光、附加光、环境光全部自己算这样跨管线兼容性好也方便做风格化控制。代价是要自己处理阴影采样和光照衰减工作量不小。但对于追求效果一致性的项目这个投入是值得的。SRP的底层原理说到底就是一句话把渲染管线的控制权从引擎手里拿过来放到你手里。你理解了这套架构就能决定每一帧、每个相机、每个物体怎么被渲染。这是从写Shader到做渲染的分水岭。我个人的体会是啃SRP源码的那段时间比写一百个Shader的收获都大因为你看清了整条链路知道每个像素是怎么从模型空间一步步走到屏幕上的。这种全局视角是调参调不出来的。