ScriptableRenderPass producing different results in WebGL build compared to Editor

Hi,

some background info first: my target platform is WebGL and I want outlines around objects in a small scene of about 50 static objects with a camera that can be moved around freely.
The “inverted hull” outlines approach (in which vertices are moved out along their normals, normals are inverted and front faces culled) doesn’t produce nice enough outlines and the mostly sharp-edged meshes I’m using would require too much manual work to get to a point where I could bake interpolated normals to vertex colors to combat this.
Since I’m targeting WebGL and want to support older devices as well, I’m hesitant to use depth and normal data and a Sobel filter for edge detection. That seems like an awful lot of extra (temporary) textures and texture lookups. Plus I could live with not having “inner” edges detected (i.e. edges are only detected between one object and another or the skybox).
I thus came up with the following idea:

  • prepare all objects to be outlined by assigning different vertex colors per object (all vertices of one object have the same color but no two objects do)
  • use a ScriptableRenderPass to draw the scene to a temporary RenderTexture using an override material with an unlit vertex color shader
  • Blit* that RenderTexture to the camera’s render target using an outline shader that detects edges in it based solely on color differences and composites the resulting edges onto the
    _CameraOpaqueTexture
  • I say Blit, but through trial and error I found that it (too) behaves strangely on WebGL. I could never get it to produce the expected result, only white or grey. So instead I use
    cmd.DrawMesh(RenderingUtils.fullscreenMesh, Matrix4x4.identity, blitMaterial);

All this works great in the editor, and I am getting outlines in WebGL builds as well, but only between the skybox and all opaque geometry instead of between differently vertex-colored objects as expected (and as in the editor). To track down why, I changed the shader used for the “Blit” step to directly output the vertex colors and all the objects appear completely white (while they should be different shades of grey as set in Blender and shown in the editor).
The vertex colors shader doesn’t seem to be the problem, when I assign it to the materials used in the scene and get rid of the ScriptableRenderPass I get the colors I expect both in the editor and in WebGL builds. I also modified it to return a fixed color to see if it always produces white on WebGL, but the fixed color persists.

The ScriptableRenderPass (set to execute “After Rendering”):

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

public class CustomOutlineFeature : ScriptableRendererFeature
{
    class RenderPass : ScriptableRenderPass
    {
        private Material grabMaterial;
        private Material blitMaterial;
        private RenderTargetIdentifier sourceID;
        private RenderTargetHandle tempTextureHandle;
        private FilteringSettings _filteringSettings;
        private List<ShaderTagId> _shaderTagIdList = new List<ShaderTagId>() { new ShaderTagId("UniversalForward"), new ShaderTagId("SRPDefaultUnlit") };

        public RenderPass(Material grabMaterial, Material blitMaterial) : base()
        {
            this.grabMaterial = grabMaterial;
            this.blitMaterial = blitMaterial;
            tempTextureHandle.Init("_TempBlitMaterialTexture");
        }

        public void SetSource(RenderTargetIdentifier source)
        {
            this.sourceID = source;
        }

        public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor)
        {
            _filteringSettings = new FilteringSettings(RenderQueueRange.opaque);
            cmd.GetTemporaryRT(tempTextureHandle.id, cameraTextureDescriptor);
            ConfigureTarget(tempTextureHandle.Identifier());
            //ConfigureTarget(sourceID);
            ConfigureClear(ClearFlag.All, Color.black);
        }

        public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
        {
            CommandBuffer cmd = CommandBufferPool.Get();
           
            SortingCriteria sortingCriteria = renderingData.cameraData.defaultOpaqueSortFlags;
            DrawingSettings drawingSettings = CreateDrawingSettings(_shaderTagIdList, ref renderingData, sortingCriteria);
            drawingSettings.overrideMaterial = grabMaterial;
           
            context.DrawRenderers(renderingData.cullResults, ref drawingSettings, ref _filteringSettings);// drawn into tempTexture
           
            cmd.SetGlobalTexture("_MainTex", tempTextureHandle.Identifier());

            cmd.SetRenderTarget(sourceID);
           
            cmd.DrawMesh(RenderingUtils.fullscreenMesh, Matrix4x4.identity, blitMaterial);
           
            context.ExecuteCommandBuffer(cmd);
            CommandBufferPool.Release(cmd);
        }

        public override void FrameCleanup(CommandBuffer cmd)
        {
            cmd.ReleaseTemporaryRT(tempTextureHandle.id);
        }
    }

    [System.Serializable]
    public class Settings {
        public Material grabMaterial;
        public Material blitMaterial;
        public int materialPassIndex = -1;
        public RenderPassEvent renderEvent = RenderPassEvent.AfterRenderingOpaques;
    }

    [SerializeField]
    private Settings settings = new Settings();
    [SerializeField]
    private Settings secondSettings = new Settings();

    RenderPass renderPass;
    private GrabRenderPass grabPass;

    public Material GrabMaterial => settings.grabMaterial;
    public Material BlitMaterial => settings.blitMaterial;

    public override void Create()
    {
        this.renderPass = new RenderPass(settings.grabMaterial, settings.blitMaterial);
        renderPass.renderPassEvent = settings.renderEvent;
    }

    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        if (renderingData.cameraData.cameraType == CameraType.Game)
        {
            renderPass.ConfigureInput(ScriptableRenderPassInput.Color);
            renderPass.SetSource(renderer.cameraColorTarget);
           
            renderer.EnqueuePass(renderPass);
        }
    }
}

The unlit vertex colors shader:

Shader "Unlit/UnlitVertexColors"
{
    Properties
    {
    }
    SubShader
    {
        Tags { "Queue"="Geometry" "RenderType"="Opaque" }
       
        Cull off

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float4 vertexColors : COLOR;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
                float4 vertexColors : COLOR;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.vertexColors = v.vertexColors;
                return o;
            }

            fixed4 frag (v2f i) : COLOR//SV_Target
            {
                fixed4 col = i.vertexColors;
                // fixed color works, vertex colors don't
                return col;
            }
            ENDCG
        }
    }
}

The edge detection shader:

Shader "Unlit/OutlineBlitClipSpaceShader"
{
    SubShader
    {
        Cull Off
        ZWrite Off
        ZTest Always

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            float4 _MainTex_TexelSize;

            sampler2D _CameraOpaqueTexture;
            float4 _CameraOpaqueTexture_ST;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = float4(v.vertex.xyz, 1.0);
                o.uv = v.uv;

                #if UNITY_UV_STARTS_AT_TOP
                    o.vertex.y *= -1;
                #endif
               
                return o;
            }

            fixed4 SamplePixel(in float2 uv)
            {
                return tex2D(_MainTex, uv);
            }

            fixed4 frag (v2f i) : SV_Target
            {
                float2 offsets[4] = {
                    float2(-1, -1),
                    float2(-1, 1),
                    float2(1, -1),
                    float2(1, 1)
                };
               
                // sample the texture
                const fixed4 originalFragment = tex2D(_CameraOpaqueTexture, i.uv);
               
                const fixed4 currentPixelColor = SamplePixel(i.uv);
                const float2 edgeWeight = float2(0.5, 0.5);
                fixed4 sampledValue = fixed4(0, 0, 0, 0);
                for (int j = 0; j < 4; j++) {
                    sampledValue += SamplePixel(i.uv + offsets[j] * _MainTex_TexelSize.xy * edgeWeight);
                }
                sampledValue /= 4;
                return lerp(originalFragment, /*_EdgeColor*/float4(0.99, 0.4, 0.4, 1), step(/*_Threshold*/0.0001, length(currentPixelColor - sampledValue)));
            }
            ENDCG
        }
    }
}

Funny thing: I created a new project and imported the two shaders, the ScriptableRenderPass and one of the vertex-colored meshes and that works as expected, even in WebGL builds.
To me this indicates that something in the project got corrupted, but I still couldn’t get the original project to produce fully functional WebGL builds. I tried switching to a newer Unity version, using a new URP Renderer asset, a new Renderer asset with a new pipeline asset and a new scene containing only the mesh I used in the new project. Still the same problem as in the original post. I also closed the project, removed everything in Library/ and Temp/ and let everything re-import, rebuild etc. but it’s still not working.
So how can I “clean” the project to make the builds work like in fresh projects without losing any settings? Or narrow down what part causes this behaviour?

For posterity: finally had a helpful hunch - the “Optimize Mesh Data” option in the player settings caused the vertex colors to go missing.
EDIT: Upon further reflection, what I wrote below doesn’t make much sense, since the optimization is done per mesh. How can I preserve vertex colors on a mesh without assigning a material to it (directly) that uses them?
Initially I thought I understood why the use of vertex colors isn’t detected and thus the data removed (the shader using vertex colors is only used in the one material referenced by the ScriptableRenderPass but never referenced by any MeshRenderer in the scene directly), but adding a GameObject to the scene with the vertex color material and a mesh containing vertex colors still doesn’t prevent vertex colors from disappearing.
I don’t want to disable “Optimize Mesh Data” altogether, so I will experiment further and make a new thread or submit an issue.