UNITY自定义渲染管线SRP探究

要深入掌握URP管线,首先需要理解其基础------SRP的设计哲学和机制。

SRP提供了渲染管线的可编程框架和核心API,而URP则是SRP的官方优化实现。

unity官方参考文档:https://docs.unity3d.com/Packages/com.unity.render-pipelines.core@17.0/manual/index.html

UNITY SRP传统的处理方式

创建一个类继承RenderPipelineAsset,附带自定义的配置

复制代码
using UnityEngine;
using UnityEngine.Rendering;

[CreateAssetMenu(menuName = "Rendering/KerzhRenderPipelineAsset")]
public class KerzhRenderPipelineAsset : RenderPipelineAsset
{
    public Color exampleColor;
    public string exampleString;
    
    // 在渲染第一帧之前,Unity会调用此方法
    // 如果渲染管线资源(Render Pipeline Asset)上的设置发生更改,Unity会在渲染下一帧之前销毁当前的渲染管线实例(Render Pipeline Instance)并再次调用此方法
    protected override RenderPipeline CreatePipeline() {
        return new KerzhRenderPipelineInstance(this);
    }
}

创建一个类继承RenderPipeline,处理详细的渲染流程,这里创建一个最基础的流程

复制代码
using UnityEngine;
using UnityEngine.Rendering;

public class KerzhRenderPipelineInstance : RenderPipeline
{
    private KerzhRenderPipelineAsset kerzhRenderPipelineAsset;
    public KerzhRenderPipelineInstance(KerzhRenderPipelineAsset kerzhRenderPipelineAsset)
    {
        this.kerzhRenderPipelineAsset = kerzhRenderPipelineAsset;
    }

    protected override void Render (ScriptableRenderContext context, Camera[] cameras) {
        //  定制自己的渲染流程
        //  基础的渲染循环为 清空上一帧的渲染目标-渲染剔除-渲染绘制

        #region 清空上一帧的渲染目标(包括屏幕和渲染texture)
        //  声明命令
        var cmd = new CommandBuffer();
        cmd.name = "ClearRenderTarget Command";
        //  命令清空渲染目标
        cmd.ClearRenderTarget(true, true, Color.black);
        //  添加到上下文中,这个指令在第一个相机sumit的时候才真正执行
        context.ExecuteCommandBuffer(cmd);
        //  释放命令
        cmd.Release();
        #endregion

        //  这里为什么逐相机处理?虽然可以在所有相机的指令都添加完成后只提交一次,也尽管这样的性能确实更好,但这也有一些问题
        //  问题示例一:如果多个相机之间存在依赖关系,如画中画效果,则必须中间产生提交
        //  问题示例二:合并提交无法通过Frame Debugger查看每个相机的渲染阶段
        //  问题示例三:合并过多命令可能会导致GPU内存压力增大
        foreach (var camera in cameras)
        {
            #region 渲染剔除(主要是摄像机剔除)
            //  获取相机的剔除参数
            camera.TryGetCullingParameters(out ScriptableCullingParameters cameraCullingParameters);
            //  可以根据情况选择是否要修改相机的剔除设置
            //  使用剔除参数去执行剔除操作
            CullingResults cameraCullingResults = context.Cull(ref cameraCullingParameters);
            #endregion
            
            //  更新shader中的相机相关参数
            context.SetupCameraProperties(camera);
            
            #region 渲染绘制
            //  获取要绘制的lightmodeId
            ShaderTagId shaderTagId = new ShaderTagId("KerzhLightMode");
            //  根据相机创建排序设置
            var sortingSettings = new SortingSettings(camera);
            //  创建绘制设置
            DrawingSettings drawingSettings = new DrawingSettings(shaderTagId, sortingSettings);
            //  过滤设置 默认为无过滤
            FilteringSettings filteringSettings = FilteringSettings.defaultValue;
            //  调用绘制指令 传入剔除结果,绘制设置和过滤设置
            context.DrawRenderers(cameraCullingResults, ref drawingSettings, ref filteringSettings);
            //  如有需要绘制天空盒
            if (camera.clearFlags == CameraClearFlags.Skybox && RenderSettings.skybox != null)
            {
                context.DrawSkybox(camera);
            }
            #endregion
            
            //  提交上下文中的所有指令,并清空指令
            context.Submit();
        }
        
    }
}

渲染管线需要和配套的shader一起使用,比如在这个管线中处理的LightMode为KerzhLightMode,所以shader中的LightMode标签也要写为对应的内容才可被管线处理

复制代码
Shader "KerzhRenderPipeline/KerzhShader"
{
    SubShader
    {
        Pass
        {
            // LightMode Pass标签的值必须与ScriptableRenderContext.DrawRenderers中的ShaderTagId相匹配
            Tags { "LightMode" = "KerzhLightMode"}

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

	        float4x4 unity_MatrixVP;
            float4x4 unity_ObjectToWorld;

            struct Attributes
            {
                float4 positionOS   : POSITION;
            };

            struct Varyings
            {
                float4 positionCS : SV_POSITION;
            };

            Varyings vert (Attributes IN)
            {
                Varyings OUT;
                float4 worldPos = mul(unity_ObjectToWorld, IN.positionOS);
                OUT.positionCS = mul(unity_MatrixVP, worldPos);
                return OUT;
            }

            float4 frag (Varyings IN) : SV_TARGET
            {
                return float4(0.5,1,0.5,1);
            }
            ENDHLSL
        }
    }
}

UNITY SRP现代的处理方式

上述为传统的srp处理方式,现代的srp使用Render Graph System(2022.3+)处理渲染流程,具体优势在如下几个方面。

1.Render Graph System可提供更高效的内存处理,传统方式在处理复杂渲染状态时,特别是渲染流程需要同时兼容很多种状态开关但同时仅有部分状态开启时,容易导致申请了这些状态的资源但最终却没有去使用,导致无效分配。而Render Graph System仅根据实际帧使用的资源进行分配,可以更高效地使用内存。

2.更现代化的资源自动管理,无需再手动释放RenderTexture等资源,编写维护更轻松

3.更智能的流程处理,在传统渲染管线中,开发者需要手动管理 Pass 之间的依赖(例如 Pass B 需要等待 Pass A 完成渲染纹理写入后才能读取)。Render Graph System会自动分析 Pass 之间的数据流依赖,对无依赖的 Pass 启用并行执行,对有依赖的 Pass 插入隐式同步点,可减少渲染管线总体所需GPU时间。

对于使用Render Graph System的重要原则:

1.不再直接处理资源,而是使用api的句柄,实际的资源只能在渲染过程中访问。

2.框架要求显式声明每个渲染过程的读取和写入资源,每个Render Graph的资源互相独立,也无法延续,如需要延续的资源须在Render Graph外创建后导入。

3.Render Graph主要使用RTHandles处理纹理,这对着色器的编写和设置有较多影响。

4.当调用Render Graph的创建api是不会立即创建资源,这会延迟到第一个使用这个资源的地方。

5.Render Graph是三步流程:设置(声明要执行的渲染过程以及每个过程所需的资源)-编译(会将未使用的通道剔除)-执行(计算资源的生命周期,处理通道依赖)。

使用Render Graph书写的基础渲染循环

复制代码
using UnityEngine;
using UnityEngine.Experimental.Rendering;
using UnityEngine.Rendering;
using UnityEngine.Rendering.RenderGraphModule;

public class KerzhRenderPipelineInstance : RenderPipeline
{
    private readonly RenderGraph m_RenderGraph = new RenderGraph("KerzhRenderGraph");
    private readonly KerzhRenderPipelineAsset m_kerzhRenderPipelineAsset;

    public KerzhRenderPipelineInstance(KerzhRenderPipelineAsset kerzhRenderPipelineAsset)
    {
        this.m_kerzhRenderPipelineAsset = kerzhRenderPipelineAsset;
    }

    /// <summary>
    /// 每帧渲染的主入口(unity调用)
    /// </summary>
    /// <param name="context"></param>
    /// <param name="cameras"></param>
    protected override void Render(ScriptableRenderContext context, Camera[] cameras)
    {
        // 每帧开始时设置 RenderGraph 参数
        // - 创建并命名命令缓冲区
        // - 设置当前帧索引
        // - 传入可编程渲染上下文
        var renderGraphParams = new RenderGraphParameters()
        {
            commandBuffer = new CommandBuffer { name = "KerzhRenderGraph" },
            currentFrameIndex = Time.frameCount,
            scriptableRenderContext = context
        };

        // 开始记录RenderGraph(开始构建渲染流程)
        m_RenderGraph.BeginRecording(renderGraphParams);
        
        // 遍历所有相机,为每个相机执行渲染
        foreach (var camera in cameras)
        {
            RenderCamera(context, camera);
        }

        // 结束当前帧的RenderGraph处理(清理资源等)
        m_RenderGraph.EndFrame();
    }
    
    // 相机渲染数据容器类
    class CameraData
    {
        public Camera camera;  // 当前渲染的相机
        public TextureHandle colorBuffer;  // 颜色缓冲区句柄
        public CullingResults cullingResults;  // 剔除结果
    }

    /// <summary>
    /// 渲染单个相机
    /// </summary>
    /// <param name="context"></param>
    /// <param name="camera"></param>
    void RenderCamera(ScriptableRenderContext context, Camera camera)
    {
        // 设置相机相关Shader参数(如VP矩阵等)
        context.SetupCameraProperties(camera);

        #region 渲染剔除(主要是摄像机剔除)
        // 确定渲染目标:如果是相机渲染到纹理则使用该纹理,否则使用默认相机目标(就是拿到相机当前使用的缓冲区)
        RenderTargetIdentifier cameraTarget = camera.targetTexture != null ? 
            new RenderTargetIdentifier(camera.targetTexture) : 
            BuiltinRenderTextureType.CameraTarget;
        
        // 创建相机数据对象
        var cameraData = new CameraData
        {
            camera = camera,
            // 导入后台缓冲区,本质是把外部的缓冲区资源导入内部以供渲染使用,这样才能绘制到相机的缓冲区上
            colorBuffer = m_RenderGraph.ImportBackbuffer(cameraTarget)
        };

        // 获取相机的剔除参数(视锥体、遮挡等)
        camera.TryGetCullingParameters(out var cullingParams);
        // 执行剔除操作,获取可见的渲染器列表
        cameraData.cullingResults = context.Cull(ref cullingParams);
        #endregion

        // 构建该相机的渲染流程(添加各种Pass)
        BuildRenderGraph(cameraData);

        // 结束RenderGraph的记录并执行所有添加的Pass
        // 这里本质是API内部使用了context的执行命令,将上述pass转换为指令并自动处理资源依赖后加入context渲染指令流程
        m_RenderGraph.EndRecordingAndExecute();
        // 提交上下文中的所有渲染指令到GPU,并清空指令队列
        context.Submit();
    }

    /*
     * 对于Render Graph来说,各个AddRenderPass在代码中的调用顺序并不重要,在将指令传入context前
     * Render Graph会根据传入的依赖决定实际的执行顺序,比如这里AddOpaquePass需要AddClearPass返回的clearedColor
     * 所以AddOpaquePass的实际执行会在AddClearPass,而Render Graph的一个重要优化也在这里
     * 当Render Graph发现各个pass间不存在资源依赖且未手动添加同步点(AddSyncPoint),且硬件支持多线程渲染时
     * Render Graph会并行执行这些pass进而提升渲染效率
     */
    /// <summary>
    /// 构建渲染流程(添加各种Pass)
    /// </summary>
    /// <param name="cameraData"></param>
    void BuildRenderGraph(CameraData cameraData)
    {
        // ========== 清屏Pass ==========
        // 添加清屏Pass,清除颜色和深度缓冲区
        TextureHandle clearedColor = AddClearPass(
            cameraData.colorBuffer, 
            Color.black, 
            clearDepth: true);

        // ========== 不透明物体Pass ==========
        // 添加不透明物体渲染Pass,使用自定义的Shader标签"KerzhLightMode"
        TextureHandle opaqueColor = AddOpaquePass(
            cameraData.cullingResults,
            new ShaderTagId("KerzhLightMode"),
            clearedColor, cameraData.camera);

        // ========== 天空盒Pass ==========
        // 如果相机设置了天空盒且场景中有天空盒材质,则渲染天空盒
        if (cameraData.camera.clearFlags == CameraClearFlags.Skybox && 
            RenderSettings.skybox != null)
        {
            opaqueColor = AddSkyboxPass(opaqueColor, cameraData.camera);
        }

        // ========== 最终输出 ==========
        // 添加最终的Blit Pass,将结果复制到最终输出缓冲区
        AddBlitPass(opaqueColor, cameraData.colorBuffer);
    }

    #region Render Passes
    /// <summary>
    /// 添加清屏Pass
    /// </summary>
    /// <param name="target">要处理的纹理</param>
    /// <param name="clearColor">清除颜色后赋值为此颜色</param>
    /// <param name="clearDepth">是否清理深度缓冲区</param>
    /// <returns></returns>
    TextureHandle AddClearPass(TextureHandle target, Color clearColor, bool clearDepth)
    {
        // 使用using确保builder正确释放
        using (var builder = m_RenderGraph.AddRenderPass<ClearPassData>(
            "Clear Pass", out var passData))
        {
            // 声明Pass将写入colorBuffer(这里会自动绑定为颜色附件0)
            passData.colorBuffer = builder.WriteTexture(target);
            
            // 设置渲染函数(实际执行时调用)
            builder.SetRenderFunc((ClearPassData data, RenderGraphContext ctx) =>
            {
                // 执行清屏操作
                ctx.cmd.ClearRenderTarget(clearDepth, true, clearColor);
            });
            
            // 返回处理后的纹理句柄(用于后续Pass)
            return passData.colorBuffer;
        }
    }

    /// <summary>
    /// 添加不透明物体渲染Pass
    /// </summary>
    /// <param name="cullResults">相机的剔除结果</param>
    /// <param name="shaderTag">shader标签</param>
    /// <param name="colorBuffer">目标渲染缓冲</param>
    /// <param name="camera">对应相机</param>
    /// <returns></returns>
    TextureHandle AddOpaquePass(CullingResults cullResults, ShaderTagId shaderTag, TextureHandle colorBuffer, Camera camera)
    {
        using (var builder = m_RenderGraph.AddRenderPass<OpaquePassData>(
            "Opaque Pass", out var passData))
        {
            // 声明使用颜色缓冲区(会绘制到这个缓冲区)
            // 1. 设置clearedColor为颜色附件0(这里的颜色附件0其实就是shader的片元着色器返回的语义SV_TARGET,它等价于SV_TARGET0,区别于不同设备通常可用区间为4-8个颜色附件)
            // 2. 自动关联深度缓冲区
            // 3. 适用于主渲染Pass
            passData.colorBuffer = builder.UseColorBuffer(colorBuffer, 0);
            // 传递剔除结果
            passData.cullResults = cullResults;
            // 指定使用的Shader标签
            passData.shaderTag = shaderTag;
            // 传递相机数据
            passData.camera = camera;

            // 设置渲染函数
            builder.SetRenderFunc((OpaquePassData data, RenderGraphContext ctx) =>
            {
                // 设置排序方式(常见的不透明物体排序)
                var sorting = new SortingSettings(data.camera) 
                { 
                    criteria = SortingCriteria.CommonOpaque 
                };
                
                // 创建绘制设置,指定使用的Shader Pass
                var drawSettings = new DrawingSettings(
                    data.shaderTag, sorting);
                
                // 创建过滤设置,只渲染不透明队列的物体
                var filterSettings = new FilteringSettings(
                    RenderQueueRange.opaque);

                // 执行绘制命令
                ctx.renderContext.DrawRenderers(
                    data.cullResults, ref drawSettings, ref filterSettings);
            });

            // 返回处理后的颜色缓冲区
            return passData.colorBuffer;
        }
    }

    /// <summary>
    /// 添加天空盒渲染Pass
    /// </summary>
    /// <param name="colorBuffer">目标渲染缓冲</param>
    /// <param name="camera">对应相机</param>
    /// <returns></returns>
    TextureHandle AddSkyboxPass(TextureHandle colorBuffer, Camera camera)
    {
        using (var builder = m_RenderGraph.AddRenderPass<SkyboxPassData>(
            "Skybox Pass", out var passData))
        {
            // 声明使用颜色缓冲区
            passData.colorBuffer = builder.UseColorBuffer(colorBuffer, 0);
            // 传递相机数据
            passData.camera = camera;

            // 设置渲染函数
            builder.SetRenderFunc((SkyboxPassData data, RenderGraphContext ctx) =>
            {
                // 绘制天空盒
                ctx.renderContext.DrawSkybox(data.camera);
            });

            // 返回处理后的颜色缓冲区
            return passData.colorBuffer;
        }
    }

    /// <summary>
    /// 添加Blit(复制)Pass
    /// </summary>
    /// <param name="source">复制源</param>
    /// <param name="dest">复制目标</param>
    void AddBlitPass(TextureHandle source, TextureHandle dest)
    {
        using (var builder = m_RenderGraph.AddRenderPass<BlitPassData>(
                   "Final Blit", out var passData))
        {
            // 这种声明式编程正是现代渲染管线的核心进步之处
            // 声明读取源纹理,这里使用ReadTexture即是Render Graph的精髓,强制等待所有对source的写入操作完成,也是pass间依赖的依据
            passData.source = builder.ReadTexture(source);
            // 声明写入目标纹理,这里使用WriteTexture即是Render Graph的精髓,他会保证在写入时不被其他pass扰乱
            // 标记该纹理进入"被修改"状态,触发后续依赖它的 Pass 等待,也是pass间依赖的依据
            passData.dest = builder.WriteTexture(dest);

            // 设置渲染函数
            builder.SetRenderFunc((BlitPassData data, RenderGraphContext ctx) => 
            {
                // 获取源和目标纹理标识符
                RenderTargetIdentifier srcIdentifier = data.source;
                RenderTargetIdentifier dstIdentifier = data.dest;
            
                // 执行Blit操作(复制纹理)
                ctx.cmd.Blit(srcIdentifier, dstIdentifier);
            });
        }
    }
    #endregion

    #region Pass Data Classes
    class ClearPassData
    {
        public TextureHandle colorBuffer;
    }

    class OpaquePassData
    {
        public TextureHandle colorBuffer;
        public CullingResults cullResults;
        public ShaderTagId shaderTag;
        public Camera camera;
    }

    class SkyboxPassData
    {
        public TextureHandle colorBuffer;
        public Camera camera;
    }

    class BlitPassData
    {
        public TextureHandle source;
        public TextureHandle dest;
    }
    #endregion

    /// <summary>
    /// 清理资源
    /// </summary>
    /// <param name="disposing"></param>
    protected override void Dispose(bool disposing)
    {
        // 清理RenderGraph
        m_RenderGraph.Cleanup();
        // 调用基类清理方法
        base.Dispose(disposing);
    }
}

Render Graph处理的渲染流程就非常清晰切省略了很多资源管理的篇幅。

对于Render Graph来说,各个AddRenderPass在代码中的调用顺序并不重要,在将指令传入context前Render Graph会根据传入的依赖决定实际的执行顺序,比如这里AddOpaquePass需要AddClearPass返回的clearedColor,所以AddOpaquePass的实际执行会在AddClearPass,而Render Graph的一个重要优化也在这里。

当Render Graph发现各个pass间不存在资源依赖且未手动添加同步点(AddSyncPoint),且硬件支持多线程渲染时,Render Graph会并行执行这些pass进而提升渲染效率。

具体的pass间执行顺序判断就取决于资源相关API的调用,在Render Graph中读取和写入纹理时都使用特定的API如ReadTexture和WriteTexture,而这种声明式编程正是现代渲染管线的核心进步之处。

ReadTexture会强制等待所有对source的写入操作完成,也是pass间依赖的依据。

WriteTexture会保证在写入时不被其他pass扰乱,同时标记该纹理进入"被修改"状态,触发后续依赖它的 Pass 等待,也是pass间依赖的依据。

现状分析

截止到unity6,根据unity的UniversalRenderPipeline.cs文件源码,unity并未完成向Render Graph的全面过渡,目前URP仅可通过 RenderGraphSettings 开启实验性支持,这种现状或是unity的兼容性考虑。而RTHanle作为单纹理层面的资源管理方案在Render Graph中作为兼容实现,后续应会逐步过渡到更现代的Render Graph处理。但Render Graph在动态分辨率支持度上依旧受限不敌RTHandle,虽然RTHandle 在特定场景仍不可替代,但更先进的管线应基于Render Graph开发。