using System.Collections.Generic; using UnityEngine; using UnityEngine.Experimental.Rendering; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; using UnityEngine.Rendering.RenderGraphModule; namespace MS_Outline { /// /// URP Renderer Feature that creates a screen-space glow effect around highlighted objects. /// Add this to your URP Renderer asset. /// public class GlowOutlineRendererFeature : ScriptableRendererFeature { [System.Serializable] public class FullOutlineSettings { [Header("Render Settings")] [Tooltip("When to inject the outline pass")] public RenderPassEvent renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing; [Tooltip("Downscale factor for blur (1 = full res, 2 = half, 4 = quarter)")] [Range(1, 4)] public int downsample = 2; [Header("Blur Settings")] [Tooltip("Number of blur iterations (more = softer glow, higher cost)")] [Range(1, 8)] public int blurIterations = 2; [Tooltip("Base blur spread")] [Range(0.5f, 3f)] public float blurSpread = 0.5f; [Header("Inner Edge Settings")] [Tooltip("Erode silhouette to hide aliased inner edge (pixels). 0 = no erosion.")] [Range(0, 8)] public int innerEdgeErosion = 5; [Header("Occlusion Settings")] [Tooltip("Number of depth dilation iterations (more = wider occlusion coverage)")] [Range(1, 16)] public int depthDilationIterations = 7; [Tooltip("Dilation spread per iteration")] [Range(0.5f, 3f)] public float dilationSpread = 1.03f; [Tooltip("Minimum depth bias for occlusion comparison (prevents z-fighting at close range)")] [Range(0f, 0.01f)] public float occlusionBiasMin = 0.000013f; [Tooltip("Depth bias scale - multiplied by object depth (handles varying distances)")] [Range(0f, 0.1f)] public float occlusionBiasScale = 0.0305f; [Header("Glow Settings")] [Tooltip("Overall glow intensity multiplier")] [Range(0f, 3f)] public float glowIntensity = 1.27f; [Header("Debug")] [Tooltip("Show debug visualization instead of final output")] public DebugMode debugMode = DebugMode.None; [Header("Shaders")] public Shader silhouetteShader; public Shader blurShader; public Shader compositeShader; public Shader depthDilateShader; public Shader erodeShader; } public enum DebugMode { None, Silhouette, ErodedSilhouette, ObjectDepth, DilatedDepth, OcclusionMask, BlurredGlow } public FullOutlineSettings settings = new FullOutlineSettings(); private FullOutlinePass outlinePass; private Material silhouetteMaterial; private Material blurMaterial; private Material compositeMaterial; private Material depthDilateMaterial; private Material erodeMaterial; public override void Create() { // Load shaders if not assigned if (settings.silhouetteShader == null) { settings.silhouetteShader = Shader.Find("MS_RPG/FullOutline/Silhouette"); if (settings.silhouetteShader == null) Debug.LogError("[FullOutline] Could not find Silhouette shader!"); } if (settings.blurShader == null) { settings.blurShader = Shader.Find("MS_RPG/FullOutline/KawaseBlur"); if (settings.blurShader == null) Debug.LogError("[FullOutline] Could not find KawaseBlur shader!"); } if (settings.compositeShader == null) { settings.compositeShader = Shader.Find("MS_RPG/FullOutline/Composite"); if (settings.compositeShader == null) Debug.LogError("[FullOutline] Could not find Composite shader!"); } if (settings.depthDilateShader == null) { settings.depthDilateShader = Shader.Find("MS_RPG/FullOutline/DepthDilate"); if (settings.depthDilateShader == null) Debug.LogError("[FullOutline] Could not find DepthDilate shader!"); } if (settings.erodeShader == null) { settings.erodeShader = Shader.Find("MS_RPG/FullOutline/Erode"); if (settings.erodeShader == null) Debug.LogError("[FullOutline] Could not find Erode shader!"); } // Create materials if (settings.silhouetteShader != null) silhouetteMaterial = CoreUtils.CreateEngineMaterial(settings.silhouetteShader); if (settings.blurShader != null) blurMaterial = CoreUtils.CreateEngineMaterial(settings.blurShader); if (settings.compositeShader != null) compositeMaterial = CoreUtils.CreateEngineMaterial(settings.compositeShader); if (settings.depthDilateShader != null) depthDilateMaterial = CoreUtils.CreateEngineMaterial(settings.depthDilateShader); if (settings.erodeShader != null) erodeMaterial = CoreUtils.CreateEngineMaterial(settings.erodeShader); outlinePass = new FullOutlinePass(settings, silhouetteMaterial, blurMaterial, compositeMaterial, depthDilateMaterial, erodeMaterial); outlinePass.renderPassEvent = settings.renderPassEvent; } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { // Only render if we have active targets if (GlowOutlineTarget.ActiveTargets.Count == 0) return; // Don't render in scene view previews or reflection probes if (renderingData.cameraData.cameraType != CameraType.Game && renderingData.cameraData.cameraType != CameraType.SceneView) return; if (outlinePass != null && outlinePass.IsValid()) { renderer.EnqueuePass(outlinePass); } } protected override void Dispose(bool disposing) { CoreUtils.Destroy(silhouetteMaterial); CoreUtils.Destroy(blurMaterial); CoreUtils.Destroy(compositeMaterial); CoreUtils.Destroy(depthDilateMaterial); CoreUtils.Destroy(erodeMaterial); } } /// /// The actual render pass that performs silhouette rendering, blur, and compositing /// public class FullOutlinePass : ScriptableRenderPass { private GlowOutlineRendererFeature.FullOutlineSettings settings; private Material silhouetteMaterial; private Material blurMaterial; private Material compositeMaterial; private Material depthDilateMaterial; private Material erodeMaterial; // Property IDs private static readonly int ColorId = Shader.PropertyToID("_Color"); private static readonly int IntensityId = Shader.PropertyToID("_Intensity"); private static readonly int OffsetId = Shader.PropertyToID("_Offset"); private static readonly int GlowIntensityId = Shader.PropertyToID("_GlowIntensity"); private static readonly int OcclusionBiasMinId = Shader.PropertyToID("_OcclusionBiasMin"); private static readonly int OcclusionBiasScaleId = Shader.PropertyToID("_OcclusionBiasScale"); public FullOutlinePass(GlowOutlineRendererFeature.FullOutlineSettings settings, Material silhouetteMaterial, Material blurMaterial, Material compositeMaterial, Material depthDilateMaterial, Material erodeMaterial) { this.settings = settings; this.silhouetteMaterial = silhouetteMaterial; this.blurMaterial = blurMaterial; this.compositeMaterial = compositeMaterial; this.depthDilateMaterial = depthDilateMaterial; this.erodeMaterial = erodeMaterial; this.profilingSampler = new ProfilingSampler("FullOutline"); } public bool IsValid() { return silhouetteMaterial != null && blurMaterial != null && compositeMaterial != null && depthDilateMaterial != null; } // PassData for silhouette rendering private class SilhouettePassData { public Material silhouetteMaterial; public TextureHandle silhouetteTexture; } // PassData for object depth rendering private class ObjectDepthPassData { public Material silhouetteMaterial; public TextureHandle depthTexture; } // PassData for blit operations private class BlitPassData { public Material material; public TextureHandle source; public TextureHandle destination; public float offset; public int passIndex; } // PassData for occlusion comparison private class OcclusionComparePassData { public Material material; public TextureHandle source; public TextureHandle destination; public float biasMin; public float biasScale; } // PassData for composite private class CompositePassData { public Material compositeMaterial; public TextureHandle sceneColor; public TextureHandle glowTexture; public TextureHandle occlusionTexture; public TextureHandle silhouetteTexture; public TextureHandle erodedSilhouetteTexture; // Eroded silhouette for subtraction (hides aliased inner edge) public TextureHandle objectDepthTexture; public TextureHandle dilatedDepthTexture; public TextureHandle tempTexture; public float glowIntensity; public float occlusionBiasMin; public float occlusionBiasScale; public GlowOutlineRendererFeature.DebugMode debugMode; } public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData) { if (!IsValid()) return; var activeTargets = GlowOutlineTarget.ActiveTargets; if (activeTargets.Count == 0) return; var resourceData = frameData.Get(); var cameraData = frameData.Get(); var descriptor = cameraData.cameraTargetDescriptor; descriptor.depthBufferBits = 0; descriptor.msaaSamples = 1; // Create HDR descriptor for glow textures (allows emission values > 1) var hdrDesc = descriptor; hdrDesc.graphicsFormat = GraphicsFormat.R16G16B16A16_SFloat; // HDR format // Create silhouette texture (full res, HDR for emission support) var silhouetteTexture = UniversalRenderer.CreateRenderGraphTexture(renderGraph, hdrDesc, "_FullOutlineSilhouette", false); // Create object depth texture (full res) - stores linear depth of outlined objects var objectDepthTexture = UniversalRenderer.CreateRenderGraphTexture(renderGraph, descriptor, "_FullOutlineObjectDepth", false); // Create dilated depth textures (full res) for ping-pong dilation var dilatedDepth1 = UniversalRenderer.CreateRenderGraphTexture(renderGraph, descriptor, "_FullOutlineDilatedDepth1", false); var dilatedDepth2 = UniversalRenderer.CreateRenderGraphTexture(renderGraph, descriptor, "_FullOutlineDilatedDepth2", false); // Create occlusion mask texture (full res) var occlusionTexture = UniversalRenderer.CreateRenderGraphTexture(renderGraph, descriptor, "_FullOutlineOcclusion", false); // Create blur textures (downsampled, HDR for emission support) var blurDesc = hdrDesc; blurDesc.width = Mathf.Max(1, blurDesc.width / settings.downsample); blurDesc.height = Mathf.Max(1, blurDesc.height / settings.downsample); var blurTexture1 = UniversalRenderer.CreateRenderGraphTexture(renderGraph, blurDesc, "_FullOutlineBlur1", false); var blurTexture2 = UniversalRenderer.CreateRenderGraphTexture(renderGraph, blurDesc, "_FullOutlineBlur2", false); // Create erosion textures (full res, HDR) for silhouette erosion - ping pong TextureHandle erodedSilhouette1 = TextureHandle.nullHandle; TextureHandle erodedSilhouette2 = TextureHandle.nullHandle; if (settings.innerEdgeErosion > 0 && erodeMaterial != null) { erodedSilhouette1 = UniversalRenderer.CreateRenderGraphTexture(renderGraph, hdrDesc, "_FullOutlineErodedSilhouette1", false); erodedSilhouette2 = UniversalRenderer.CreateRenderGraphTexture(renderGraph, hdrDesc, "_FullOutlineErodedSilhouette2", false); } // ============ Pass 1: Render silhouettes (glow color) ============ using (var builder = renderGraph.AddUnsafePass("FullOutline_Silhouette", out var passData, profilingSampler)) { passData.silhouetteMaterial = silhouetteMaterial; passData.silhouetteTexture = silhouetteTexture; builder.UseTexture(silhouetteTexture, AccessFlags.Write); builder.SetRenderFunc((SilhouettePassData data, UnsafeGraphContext context) => { var cmd = CommandBufferHelpers.GetNativeCommandBuffer(context.cmd); cmd.SetRenderTarget(data.silhouetteTexture); cmd.ClearRenderTarget(true, true, Color.clear); foreach (var target in GlowOutlineTarget.ActiveTargets) { if (target == null || target.Renderers == null) continue; data.silhouetteMaterial.SetColor(ColorId, target.GlowColor); data.silhouetteMaterial.SetFloat(IntensityId, target.GlowIntensity); foreach (var renderer in target.Renderers) { if (renderer == null) continue; for (int submesh = 0; submesh < renderer.sharedMaterials.Length; submesh++) { cmd.DrawRenderer(renderer, data.silhouetteMaterial, submesh, 0); // Pass 0 = Silhouette } } } }); } // ============ Pass 1b: Erode silhouette (shrink the mask to hide aliased inner edge) ============ // The eroded silhouette will be used for subtraction, while the original is used for blur TextureHandle finalErodedSilhouette = silhouetteTexture; // Default to original if no erosion if (settings.innerEdgeErosion > 0 && erodeMaterial != null && erodedSilhouette1.IsValid()) { TextureHandle erodeSource = silhouetteTexture; TextureHandle erodeDest = erodedSilhouette1; for (int i = 0; i < settings.innerEdgeErosion; i++) { int iterIndex = i; TextureHandle src = erodeSource; TextureHandle dst = erodeDest; using (var builder = renderGraph.AddUnsafePass($"FullOutline_Erode_{i}", out var passData, profilingSampler)) { passData.material = erodeMaterial; passData.source = src; passData.destination = dst; passData.offset = 1.0f; // 1 pixel per iteration passData.passIndex = 0; builder.UseTexture(src, AccessFlags.Read); builder.UseTexture(dst, AccessFlags.Write); builder.SetRenderFunc((BlitPassData data, UnsafeGraphContext context) => { var cmd = CommandBufferHelpers.GetNativeCommandBuffer(context.cmd); data.material.SetFloat(OffsetId, data.offset); Blitter.BlitCameraTexture(cmd, data.source, data.destination, data.material, data.passIndex); }); } // Ping-pong between textures if (i == 0) { erodeSource = erodedSilhouette1; erodeDest = erodedSilhouette2; } else { var temp = erodeSource; erodeSource = erodeDest; erodeDest = temp; } } finalErodedSilhouette = erodeSource; } // ============ Pass 2: Render object depth ============ using (var builder = renderGraph.AddUnsafePass("FullOutline_ObjectDepth", out var passData, profilingSampler)) { passData.silhouetteMaterial = silhouetteMaterial; passData.depthTexture = objectDepthTexture; builder.UseTexture(objectDepthTexture, AccessFlags.Write); builder.SetRenderFunc((ObjectDepthPassData data, UnsafeGraphContext context) => { var cmd = CommandBufferHelpers.GetNativeCommandBuffer(context.cmd); cmd.SetRenderTarget(data.depthTexture); // Clear to 1.0 (far plane) so unwritten pixels don't trigger false occlusion cmd.ClearRenderTarget(true, true, Color.white); foreach (var target in GlowOutlineTarget.ActiveTargets) { if (target == null || target.Renderers == null) continue; foreach (var renderer in target.Renderers) { if (renderer == null) continue; for (int submesh = 0; submesh < renderer.sharedMaterials.Length; submesh++) { cmd.DrawRenderer(renderer, data.silhouetteMaterial, submesh, 1); // Pass 1 = ObjectDepth } } } }); } // ============ Pass 3: Dilate object depth (expand outward using min-filter) ============ TextureHandle dilateSource = objectDepthTexture; TextureHandle dilateDest = dilatedDepth1; for (int i = 0; i < settings.depthDilationIterations; i++) { int iterIndex = i; TextureHandle src = dilateSource; TextureHandle dst = dilateDest; using (var builder = renderGraph.AddUnsafePass($"FullOutline_Dilate_{i}", out var passData, profilingSampler)) { passData.material = depthDilateMaterial; passData.source = src; passData.destination = dst; passData.offset = settings.dilationSpread; passData.passIndex = 0; builder.UseTexture(src, AccessFlags.Read); builder.UseTexture(dst, AccessFlags.Write); builder.SetRenderFunc((BlitPassData data, UnsafeGraphContext context) => { var cmd = CommandBufferHelpers.GetNativeCommandBuffer(context.cmd); data.material.SetFloat(OffsetId, data.offset); Blitter.BlitCameraTexture(cmd, data.source, data.destination, data.material, data.passIndex); }); } // Ping-pong between textures if (i == 0) { dilateSource = dilatedDepth1; dilateDest = dilatedDepth2; } else { var temp = dilateSource; dilateSource = dilateDest; dilateDest = temp; } } TextureHandle finalDilatedDepth = dilateSource; // ============ Pass 4: Generate occlusion mask (compare scene depth vs dilated object depth) ============ using (var builder = renderGraph.AddUnsafePass("FullOutline_OcclusionCompare", out var passData, profilingSampler)) { passData.material = compositeMaterial; passData.source = finalDilatedDepth; passData.destination = occlusionTexture; passData.biasMin = settings.occlusionBiasMin; passData.biasScale = settings.occlusionBiasScale; builder.UseTexture(finalDilatedDepth, AccessFlags.Read); builder.UseTexture(occlusionTexture, AccessFlags.Write); if (resourceData.cameraDepthTexture.IsValid()) builder.UseTexture(resourceData.cameraDepthTexture, AccessFlags.Read); builder.SetRenderFunc((OcclusionComparePassData data, UnsafeGraphContext context) => { var cmd = CommandBufferHelpers.GetNativeCommandBuffer(context.cmd); data.material.SetFloat(OcclusionBiasMinId, data.biasMin); data.material.SetFloat(OcclusionBiasScaleId, data.biasScale); Blitter.BlitCameraTexture(cmd, data.source, data.destination, data.material, 3); // Pass 3 = DepthOcclusion }); } // ============ Pass 5: Downsample silhouette to blur texture ============ using (var builder = renderGraph.AddUnsafePass("FullOutline_Downsample", out var passData, profilingSampler)) { passData.source = silhouetteTexture; passData.destination = blurTexture1; builder.UseTexture(silhouetteTexture, AccessFlags.Read); builder.UseTexture(blurTexture1, AccessFlags.Write); builder.SetRenderFunc((BlitPassData data, UnsafeGraphContext context) => { var cmd = CommandBufferHelpers.GetNativeCommandBuffer(context.cmd); Blitter.BlitCameraTexture(cmd, data.source, data.destination); }); } // ============ Pass 6: Blur iterations for glow ============ TextureHandle currentSource = blurTexture1; TextureHandle currentDest = blurTexture2; for (int i = 0; i < settings.blurIterations; i++) { int iterIndex = i; TextureHandle src = currentSource; TextureHandle dst = currentDest; using (var builder = renderGraph.AddUnsafePass($"FullOutline_Blur_{i}", out var passData, profilingSampler)) { passData.material = blurMaterial; passData.source = src; passData.destination = dst; passData.offset = settings.blurSpread + iterIndex; passData.passIndex = 0; builder.UseTexture(src, AccessFlags.Read); builder.UseTexture(dst, AccessFlags.Write); builder.SetRenderFunc((BlitPassData data, UnsafeGraphContext context) => { var cmd = CommandBufferHelpers.GetNativeCommandBuffer(context.cmd); data.material.SetFloat(OffsetId, data.offset); Blitter.BlitCameraTexture(cmd, data.source, data.destination, data.material, data.passIndex); }); } var temp = currentSource; currentSource = currentDest; currentDest = temp; } TextureHandle finalBlur = currentSource; TextureHandle tempTarget = currentDest; // ============ Pass 7: Debug visualization OR composite ============ using (var builder = renderGraph.AddUnsafePass("FullOutline_Composite", out var passData, profilingSampler)) { passData.compositeMaterial = compositeMaterial; passData.glowTexture = finalBlur; passData.occlusionTexture = occlusionTexture; passData.silhouetteTexture = silhouetteTexture; passData.erodedSilhouetteTexture = finalErodedSilhouette; // Use eroded silhouette for subtraction passData.objectDepthTexture = objectDepthTexture; passData.dilatedDepthTexture = finalDilatedDepth; passData.tempTexture = tempTarget; passData.sceneColor = resourceData.activeColorTexture; passData.glowIntensity = settings.glowIntensity; passData.debugMode = settings.debugMode; builder.UseTexture(finalBlur, AccessFlags.Read); builder.UseTexture(silhouetteTexture, AccessFlags.Read); if (finalErodedSilhouette.IsValid() && !finalErodedSilhouette.Equals(silhouetteTexture)) builder.UseTexture(finalErodedSilhouette, AccessFlags.Read); builder.UseTexture(occlusionTexture, AccessFlags.Read); builder.UseTexture(objectDepthTexture, AccessFlags.Read); builder.UseTexture(finalDilatedDepth, AccessFlags.Read); builder.UseTexture(tempTarget, AccessFlags.ReadWrite); builder.UseTexture(resourceData.activeColorTexture, AccessFlags.ReadWrite); builder.SetRenderFunc((CompositePassData data, UnsafeGraphContext context) => { var cmd = CommandBufferHelpers.GetNativeCommandBuffer(context.cmd); // Debug modes: show intermediate textures if (data.debugMode != GlowOutlineRendererFeature.DebugMode.None) { TextureHandle debugTex = data.debugMode switch { GlowOutlineRendererFeature.DebugMode.Silhouette => data.silhouetteTexture, GlowOutlineRendererFeature.DebugMode.ErodedSilhouette => data.erodedSilhouetteTexture, GlowOutlineRendererFeature.DebugMode.ObjectDepth => data.objectDepthTexture, GlowOutlineRendererFeature.DebugMode.DilatedDepth => data.dilatedDepthTexture, GlowOutlineRendererFeature.DebugMode.OcclusionMask => data.occlusionTexture, GlowOutlineRendererFeature.DebugMode.BlurredGlow => data.glowTexture, _ => data.glowTexture }; Blitter.BlitCameraTexture(cmd, debugTex, data.sceneColor); return; } // Build final glow step by step: // 1. Copy blurred glow to temp texture Blitter.BlitCameraTexture(cmd, data.glowTexture, data.tempTexture, data.compositeMaterial, 2); // Pass 2 = Copy // 2. Subtract eroded silhouette mask (removes glow from object center, leaving outer glow) // Using eroded silhouette allows glow to extend slightly onto the object, hiding aliased inner edge Blitter.BlitCameraTexture(cmd, data.erodedSilhouetteTexture, data.tempTexture, data.compositeMaterial, 1); // Pass 1 = SubtractMask // 3. Subtract occlusion mask (removes glow where other objects are in front) Blitter.BlitCameraTexture(cmd, data.occlusionTexture, data.tempTexture, data.compositeMaterial, 1); // Pass 1 = SubtractMask // 4. Additively blend the final glow onto the scene data.compositeMaterial.SetFloat(GlowIntensityId, data.glowIntensity); Blitter.BlitCameraTexture(cmd, data.tempTexture, data.sceneColor, data.compositeMaterial, 0); // Pass 0 = AdditiveBlend }); } } } }