美文网首页
Unity自定义SRP(一):构建渲染框架

Unity自定义SRP(一):构建渲染框架

作者: Dragon_boy | 来源:发表于2020-12-13 20:53 被阅读0次

    https://catlikecoding.com/unity/tutorials/custom-srp/

    介绍

    为了进行渲染,Unity会决定哪些图形会被绘制,会被绘制在何处,在什么时候绘制,以及用什么设置来绘制。这很复杂,包含许多效果,如灯光、阴影、透明效果、基于图片的效果、体积效果等。Unity除自带的内置渲染管线外,还提供了SRP,可让用户自定义渲染管线,官方据此给出了两种预制管线,URP和HDRP。这里会模仿URP制定一个渲染管线。

    渲染管线

    新建项目后先将颜色空间修改为Linear(Edit/Project Settings/Player/Other Settings),这是我们之后会使用的颜色空间。

    管线资产

    首先模仿URP的结构创建项目,新建一个Runtime文件夹,并新建一个CustomRenderPipelineAsset类。RP资产的目的是给让Unity得到一个pipeline对象实例,负责渲染。在类中我们需要重写抽象方法CreatePipeline,用于得到我们的渲染管线对象:

    using UnityEngine;
    using UnityEngine.Rendering;
    
    
    [CreateAssetMenu(menuName = "Rendering/Custom Render Pipeline")]
    public class CustomRenderPipelineAsset : RenderPipelineAsset
    {
        protected override RenderPipeline CreatePipeline()
        {
            return null;
        }
    }
    

    使用该脚本创建一个pipeline asset后,拖到项目设置的Graphic对应位置即可:


    渲染管线实例

    接着创建一个CustomRenderPipeline类,这是CreatePipeline会返回的实例类型。由于继承自RenderPipeline,我们需要重写Render方法:

    using UnityEngine;
    using UnityEngine.Rendering;
    
    public class CustomRenderPipeline : RenderPipeline
    {
        protected override void Render(ScriptableRenderContext context, Camera[] cameras)
        {   
        }
    }
    

    同时修改CreatePipeline返回值:

        protected override RenderPipeline CreatePipeline()
        {
            return new CustomRenderPipeline();
        }
    

    渲染

    Unity每帧都会在RP实例上调用Render方法,它有一个context结构体参数,其提供与原生引擎之间的连接,我们可以用来进行渲染,还有一个摄像机队列参数,我们可以按顺序渲染多个摄像机。

    摄像机渲染器

    每个摄像机独立渲染,因此这里建立一个CameraRenderer类,用于渲染单个摄像机:

    using UnityEngine;
    using UnityEngine.Rendering;
    
    public class CameraRenderer
    {
        public void Render(ScriptableRenderContext context, Camera camera)
        {
            this.context = context;
            this.camera = camera;
        }
    

    接着在CustomRenderPipeline中创建一个CameraRenderer实例,在Render方法中循环进行摄像机的渲染:

        CameraRenderer renderer = new CameraRenderer();
        protected override void Render(ScriptableRenderContext context, Camera[] cameras)
        {
            foreach (Camera camera in cameras)
            {
                renderer.Render(context, camera, useDynamicBatching, useGPUInstancing, shadowSettings);
            }
        }
    

    绘制天空盒

    CameraRenderer.Render的任务是绘制所有当前摄像机能看见的物体,我们将其归类到DrawVisibleGeometry方法中,这里首先绘制一个天空盒:

        public void Render(ScriptableRenderContext context, Camera camera)
        {
            this.context = context;
            this.camera = camera;
    
            DrawVisibleGeometry();
        }
        void DrawVisibleGeometry()
        {
            context.DrawSkybox(camera);
        }
    

    这样子还不能显示出天空盒,因为我们发布给context的命令是缓冲的,我们需要提交队列任务来执行,调用Submit方法:

        public void Render(ScriptableRenderContext context, Camera camera)
        {
            this.context = context;
            this.camera = camera;
    
            DrawVisibleGeometry();
            Submit();
        }
        void Submit()
        {
            context.Submit();
        }
    

    这样的话就可以绘制天空盒了。如果我们打开frame debugger的话,会发现其名字是Camera.RenderSkybox,其子列表下有一个Draw Mesh命令。

    此时摄像机的朝向还不能影响天空盒会被如何绘制。我们将camera传递到DrawSkyBox,但只决定了天空和是否被绘制,即清楚标志。为了正确的绘制天空盒,我们需要设置view-projection矩阵。我们通过SetUpCameraPeoperties方法将矩阵传递给context:

        public void Render(ScriptableRenderContext context, Camera camera)
        {
            this.context = context;
            this.camera = camera;
    
            Setup();
            DrawVisibleGeometry();
            Submit();
        }
        void Setup()
        {
            context.SetupCameraProperties(camera);
        }
    

    命令缓冲

    context会在我们提交时才会进行真正的渲染,在此之前,我们可以进行相关配置,添加一些待执行的命令。一些任务(如绘制天空和)可以直接通过相应的方法发布,但其它的命令需要通过单独的命令缓冲发布。

    这里首先创建一个命令缓冲对象,并赋予一个名字,以便在frame debugger中看到:

        const string bufferName = "Render Camera";
    
        CommandBuffer buffer = new CommandBuffer
        {
            name = bufferName
        };
    

    我们使用命令缓冲来注入profiler样本,这样就可以同时在profiler和frame debugger中看到:

        void Setup()
        {
            buffer.BeginSample(bufferName);
            context.SetupCameraProperties(camera);
        }
    
        void Submit()
        {
            buffer.EndSample(bufferName);
            context.Submit();
        }
    

    为了执行缓冲,我们使用ExecuteCommandBuffer方法,并将缓冲作为参数,他会复制来自缓冲的命令,但不会清除掉:

        void Setup()
        {
            buffer.BeginSample(bufferName);
            ExecuteBuffer();
            context.SetupCameraProperties(camera);
        }
    
        void Submit()
        {
            buffer.EndSample(bufferName);
            ExecuteBuffer();
            context.Submit();
        }
        void ExecuteBuffer()
        {
            context.ExecuteCommandBuffer(buffer);
            buffer.Clear();
        }
    

    打开frame debugger就会看到Camera.RenderSkyBox内嵌在Render Camera中。

    清除渲染目标

    我们要渲染的东西会渲染到摄像机的渲染目标中,可以是帧缓冲也可以是渲染纹理。如果我们要渲染到的目标上有之前绘制的东西,我们当前要绘制的东西就会被干扰,因此我们需要去除旧的渲染内容,使用ClearRenderTarget方法。该方法要求至少三个参数,前两个标识是否清除深度和颜色数据,第三个是要用什么颜色来清除:

        void Setup()
        {
            buffer.BeginSample(bufferName);
            buffer.ClearRenderTarget(true, true, Color.clear);
            ExecuteBuffer();
            context.SetupCameraProperties(camera);
        }
    

    此时在frame debugger中就会看到一个内嵌的Draw GL,即清除这一行为的入口点,不过它内嵌在一个额外的Render Camera中,因为我们在一个命令缓冲的样本下清除的。我们可以先清除再开始我们的样本:

        void Setup()
        {
            buffer.ClearRenderTarget(true, true, Color.clear);
            buffer.BeginSample(bufferName);
            ExecuteBuffer();
        }
    

    Draw GL本身是使用一个Hidden/InternalClear着色器来绘制一个屏幕四边形来完成的,其会写入到一个渲染目标中,这样的效率并不太高。之所以如此是因为我们是在设置摄像机前进行清除的,如果交换顺序就能高效完成:

        void Setup()
        {
            context.SetupCameraProperties(camera);
            buffer.ClearRenderTarget(true, true, Color.clear);
            buffer.BeginSample(bufferName);
            ExecuteBuffer();
        }
    

    此时查看frame debugger会发现Clear(color+Z+stencil),即清除颜色和深度缓冲。

    剔除

    为保证效率,我们只绘制摄像机看的到的物体。在绘制前,我们会剔除掉所有在视锥体外的物体。

    我们通过跟踪多个摄像机的设置和矩阵来找到要剔除的物体,这里使用ScriptableCullingParameters结构体,我们可以使用摄像机的TryGetCullingParameters方法来填充该结构体:

        bool Cull()
        {
            if (camera.TryGetCullingParameters(out ScriptableCullingParameters p))
            {
                return true;
            }
            return false;
        }
    

    Setup前调用:

        public void Render(ScriptableRenderContext context, Camera camera)
        {
            this.context = context;
            this.camera = camera;
    
            if (!Cull())
            {
                return;
            }
    
            Setup();
            DrawVisibleGeometry(useDynamicBatching, useGPUInstancing);
            Submit();
        }
    

    Cull方法中,我们调用context的Cull方法:

        bool Cull()
        {
            if (camera.TryGetCullingParameters(out ScriptableCullingParameters p))
            {
                cullingResults = context.Cull(ref p);
                return true;
            }
            return false;
        }
    

    绘制几何体

    我们调用context的DrawRenderers方法来渲染一些东西,它将剔除的结果作为参数,告诉其要使用那个渲染器。除此之外,我们还要提供drawing settingsfiltering settings,对应DrawingSettingsFilteringSettings结构体:

        void DrawVisibleGeometry()
        {
            var drawingSettings = new DrawingSettings();
            var filteringSettings = new FilteringSettings();
    
            context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);
    
            context.DrawSkybox(camera);
        }
    

    仅仅如此还不能看见要绘制的集合体,因为我们还需要指示可以使用哪些shader pass。这里使用默认的:

        static ShaderTagId unlitShaderTagId = new ShaderTagId("SRPDefaultUnlit");
    

    该参数作为DrawingSettings结构体构造函数的参数,同时提供一个SortingSettings结构体变量,传入一个摄像机参数,其可以决定是使用正交还是基于距离的排序:

        void DrawVisibleGeometry()
        {
            var sortingSettings = new SortingSettings(camera);
            var drawingSettings = new DrawingSettings(unlitShaderTagId, sortingSettings);
            ...
        }
    

    同时我们指示使用什么渲染队列:

            var filteringSettings = new FilteringSettings(RenderQueueRange.all);
    

    我们可以创建多个物体来测试,部分物体应用带透明通道的纹理。打开frame debugger我们可以看到这些Draw Mesh命令内嵌在RenderLoop.Draw中。仔细看渲染顺序,会发现透明物体的显示有问题,这是因为目前场景中物体的渲染顺序是随机的,如果想正确显示透明物体,我们需要在所有不透明物体之后渲染。我们可以在sortingSettings的criteria属性中进行设置:

            var sortingSettings = new SortingSettings(camera)
            {
                criteria = SortingCriteria.CommonOpaque
            };
    

    此时会发现物体或多或少地会根据从前往后的顺序绘制,但也只是针对不透明物体,透明物体仍不会正常显示。

    单独绘制不透明和透明几何体

    观察frame debugger的话,会发现透明物体其实绘制了,但天空盒会进行覆盖,所有不在不透明物体前的片段都会被剔除,这是因为透明物体的shader不会写入深度缓冲。因此我们手动先绘制不透明物体,然后是透明物体。

    首先将初始的渲染队列调整为opaque:

            var filteringSettings = new FilteringSettings(RenderQueueRange.opaque);
    

    在绘制天空盒后再次调用DrawRenderers,在此之前调整渲染队列为transparent,同时调整渲染顺序,从后往前渲染:

            context.DrawSkybox(camera);
    
            sortingSettings.criteria = SortingCriteria.CommonTransparent;
            drawingSettings.sortingSettings = sortingSettings;
            filteringSettings.renderQueueRange = RenderQueueRange.transparent;
    
            context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);
    

    编辑器渲染

    绘制Legacy Shader

    目前的渲染管线只支持unlit shader pass。为了方便进行管线的转换,我们将Legacy Shader包含在内:

        static ShaderTagId[] legacyShaderTagIds = {
            new ShaderTagId("Always"),
            new ShaderTagId("ForwardBase"),
            new ShaderTagId("PrepassBase"),
            new ShaderTagId("Vertex"),
            new ShaderTagId("VertexLMRGBM"),
            new ShaderTagId("VertexLM")
        };
    

    我们在绘制完所有的可见几何体后绘制所有的不支持的shader,并且只使用默认的渲染设置:

        public void Render(ScriptableRenderContext context, Camera camera)
        {
            ...
            Setup();
            DrawVisibleGeometry(useDynamicBatching, useGPUInstancing);
            DrawUnsupportedShaders();
            Submit();
        }
    
        public void DrawUnsupportedShaders()
        {
            var drawingSettings = new DrawingSettings(legacyShaderTagIds[0], new SortingSettings(camera));
            var filteringSettings = FilteringSettings.defaultValue;
            context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);
        }
    

    我们在drawing settings上调用SetShaderPassName来绘制多个pass:

            for (int i = 1; i < legacyShaderTagIds.Length; i++)
            {
                drawingSettings.SetShaderPassName(i, legacyShaderTagIds[i]);
            }
    

    错误警示材质

    当物体上的材质错误时,会显示一个警示颜色:

        static Material errorMaterial;
        ...
        public void DrawUnsupportedShaders()
        {
            if (errorMaterial == null)
            {
                errorMaterial = new Material(Shader.Find("Hidden/InternalErrorShader"));
            }
            var drawingSettings = new DrawingSettings(legacyShaderTagIds[0], new SortingSettings(camera))
            {
                overrideMaterial = errorMaterial
            };
    

    Partial类

    绘制非法物体只在编辑器中进行,因此我们将这些代码放到一个partial类中,创建一个CameraRenderer.Editor类:

    using UnityEngine;
    using UnityEngine.Rendering;
    
    partial class CameraRenderer
    {
        partial void DrawUnsupportedShaders();
    
    #if UNITY_EDITOR
    
        static ShaderTagId[] legacyShaderTagIds = {...};
    
        static Material errorMaterial;
    
        partial void DrawUnsupportedShaders()
        {...}
    
    #endif
    }
    

    绘制Gizmo

    我们通过调用UnityEditor.Handles.ShouldRenderGizmos来判断是否绘制gizmo。我们调用DrawGizmos来绘制Gizmo,第一个参数为摄像机,第二个参数为绘制哪个gizmo子集:

    using UnityEditor;
    using UnityEngine;
    using UnityEngine.Rendering;
    
    partial class CameraRenderer
    {
        partial void DrawGizmos();
    
        partial void DrawUnsupportedShaders();
    
    #if UNITY_EDITOR
    
        ...
        partial void DrawGizmos()
        {
            if (Handles.ShouldRenderGizmos())
            {
                context.DrawGizmos(camera, GizmoSubset.PreImageEffects);
                context.DrawGizmos(camera, GizmoSubset.PostImageEffects);
            }
        }
        partial void DrawUnsupportedShaders()
        {...}
    
    #endif
    

    在最后绘制:

        public void Render(ScriptableRenderContext context, Camera camera)
        {
            ...
            Setup();
            DrawVisibleGeometry();
            DrawUnsupportedShaders();
            DrawGizmos();
            Submit();
        }
    

    绘制Unity UI

    如果此时我们创建一个UI组件,会发现它只在Game窗口显示,不在Scene窗口显示。在frame debugger中会发现其被单独绘制,不由我们自己的RP绘制。

    原因是此时画布组件的Render Mode被设置为Screen Space - Overlay,如果我们将其改为Screen Space Camera,并使用主摄像机为Render Camera,即可将其作为透明物体的一部分。

    UI在设置为World Space模式时可以在场景窗口中显示。为了渲染UI,我们需要将其添加到世界物体中,通过调用ScriptableRenderContext.EmitWorldGeometryForSceneView,将摄像机作为参数:

        partial void PrepareForSceneWindow();
    #if UNITY_EDITOR
    
        partial void PrepareForSceneWindow()
        {
            if (camera.cameraType == CameraType.SceneView)
            {
                ScriptableRenderContext.EmitWorldGeometryForSceneView(camera);
            }
        }
    #endif
    

    在剔除操作前将其加入到场景的几何体中:

            PrepareForSceneWindow();
            if (!Cull(shadowSettings.maxDistance))
            {
                return;
            }
    

    多个摄像机

    两个摄像机

    每个摄像机有一个深度值,默认的主摄像机的深度值为-1,它们按照深度值逐渐递增的顺序渲染。如果我们复制main camera,命名为Secondary Camera,将其深度值设为0,接着就会发现frame debugger中两个摄像机在同一个采样范围内,即场景渲染了两次。为了让每个摄像机有自己的采样范围,我们创建一个PrepareBuffer方法,其缓冲名为摄像机名:

        partial void PrepareBuffer();
    #if UNITY_EDITOR
        ...
        partial void PrepareBuffer()
        {
            Profiler.BeginSample("Editor Only");
            buffer.name = SampleName = camera.name;
            Profiler.EndSample();
        }
    #endif
    

    处理变化缓冲名的问题

    使用上述的方法后,的确每个摄像机都有了自己的采样层级,不过进入Unity的Play模式的话,Unity会报错,警告我们BeginSampleEndSample的数量必须匹配。

    为了解决这一问题,我们添加一个SampleName的字符串属性,在编辑器模式下我们在其设置为PrepareBuffer中缓冲的名字,其余模式下简单的设为一个常量字符串Render Camera:

    #if UNITY_EDITOR
        string SampleName { get; set; }
        ...
        partial void PrepareBuffer()
        {
            buffer.name = SampleName = camera.name;
        }
    
    #else
        const string SampleName = bufferName;
    #endif
    

    使用SampleNameSetupSubmit中采样:

        void Setup()
        {
            context.SetupCameraProperties(camera);
            buffer.ClearRenderTarget(true, true, Color.clear);
            buffer.BeginSample(SampleName);
            ExecuteBuffer();
        }
    
        void Submit()
        {
            buffer.EndSample(SampleName);
            ExecuteBuffer();
            context.Submit();
        }
    

    我们可以在profiler中查看编辑模式和其他模式的区别。按照GC Alloc排序的话,会发现在编辑模式下GC.Alloc总共分配了100字节,Build模式下则没有这个100字节。

    我们可以加入一个新的样本Editor Only来标识清楚:

    using UnityEngine.Profiling;
    
    #if UNITY_EDITOR
        string SampleName { get; set; }
        ...
        partial void PrepareBuffer()
        {
            Profiler.BeginSample("Editor Only");
            buffer.name = SampleName = camera.name;
            Profiler.EndSample();
        }
    
    #else
        const string SampleName = bufferName;
    #endif
    

    清除标志

    我们可以将两个摄像机的结果组合在一起,将清除标志与另一个进行比较:

        void Setup()
        {
            context.SetupCameraProperties(camera);
            CameraClearFlags flags = camera.clearFlags;
            buffer.ClearRenderTarget(true, true, Color.clear);
            buffer.BeginSample(SampleName);
            ExecuteBuffer();
        }
    

    CameraClearFlags枚举定义了四个值:Skybox,Color,Depth和Nothing。深度缓冲除了最后一个都要被清除:

            buffer.ClearRenderTarget(flags <= CameraClearFlags.Depth, true, Color.clear);
    

    只有标志被设为Color时我们才清除颜色缓冲:

            buffer.ClearRenderTarget(flags <= CameraClearFlags.Depth, flags == CameraClearFlags.Color,
                    Color.clear);
    

    清除的颜色我们使用线性空间的摄像机背景颜色:

            buffer.ClearRenderTarget(flags <= CameraClearFlags.Depth, flags == CameraClearFlags.Color,
                    flags == CameraClearFlags.Color ? camera.backgroundColor.linear : Color.clear);
    

    由于Main Camera是首先被渲染的,因此它的Clear Flags应为Skybox或Color。Secondary Camera的Clear Flags决定了两个摄像机会如何结合。

    相关文章

      网友评论

          本文标题:Unity自定义SRP(一):构建渲染框架

          本文链接:https://www.haomeiwen.com/subject/uxlpgktx.html