Skip to content

Instantly share code, notes, and snippets.

@SudoCat
Forked from bgolus/HiddenJumpFloodOutline.shader
Last active August 16, 2023 22:25
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save SudoCat/05b954083f4cd706b8af738b9cf37860 to your computer and use it in GitHub Desktop.
Save SudoCat/05b954083f4cd706b8af738b9cf37860 to your computer and use it in GitHub Desktop.
Jump flood based outline effect for Unity, updated to URP https://medium.com/@bgolus/the-quest-for-very-wide-outlines-ba82ed442cd9
Shader "Custom/Warlocracy/CharacterShader"
{
Properties
{
_MainTex ("Sprite Texture", 2D) = "white" {}
_OutlineColor ("Outline Color", Color) = (1,1,1,1)
_OutlineWidth ("Outline Width", float) = 2
[HideInInspector] _Color ("Tint", Color) = (1,1,1,1)
[HideInInspector] _RendererColor ("RendererColor", Color) = (1,1,1,1)
}
SubShader
{
Tags { "Queue" = "Transparent" "RenderType" = "Transparent" "RenderPipeline" = "UniversalPipeline" }
Cull Off
ZWrite Off
ZTest Always
HLSLINCLUDE
// just inside the precision of a R16G16_SNorm to keep encoded range 1.0 >= and > -1.0
#define SNORM16_MAX_FLOAT_MINUS_EPSILON ((float)(32768-2) / (float)(32768-1))
#define FLOOD_ENCODE_OFFSET float2(1.0, SNORM16_MAX_FLOAT_MINUS_EPSILON)
#define FLOOD_ENCODE_SCALE float2(2.0, 1.0 + SNORM16_MAX_FLOAT_MINUS_EPSILON)
#define FLOOD_NULL_POS -1.0
#define FLOOD_NULL_POS_FLOAT2 float2(FLOOD_NULL_POS, FLOOD_NULL_POS)
ENDHLSL
// This pass is copied straight from Sprite-Unlit-Default
Pass
{
Name "SPRITE RENDER"
Blend SrcAlpha OneMinusSrcAlpha
Tags { "LightMode" = "Universal2D" }
HLSLPROGRAM
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#if defined(DEBUG_DISPLAY)
#include "Packages/com.unity.render-pipelines.universal/Shaders/2D/Include/InputData2D.hlsl"
#include "Packages/com.unity.render-pipelines.universal/Shaders/2D/Include/SurfaceData2D.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Debug/Debugging2D.hlsl"
#endif
#pragma vertex UnlitVertex
#pragma fragment UnlitFragment
#pragma multi_compile _ DEBUG_DISPLAY
struct attributes
{
float3 positionOS : POSITION;
float4 color : COLOR;
float2 uv : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct Varyings
{
float4 positionCS : SV_POSITION;
half4 color : COLOR;
float2 uv : TEXCOORD0;
#if defined(DEBUG_DISPLAY)
float3 positionWS : TEXCOORD2;
#endif
UNITY_VERTEX_OUTPUT_STEREO
};
TEXTURE2D(_MainTex);
SAMPLER(sampler_MainTex);
half4 _MainTex_ST;
float4 _Color;
half4 _RendererColor;
Varyings UnlitVertex(attributes v)
{
Varyings o = (Varyings)0;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
o.positionCS = TransformObjectToHClip(v.positionOS);
#if defined(DEBUG_DISPLAY)
o.positionWS = TransformObjectToWorld(v.positionOS);
#endif
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.color = v.color * _Color * _RendererColor;
return o;
}
half4 UnlitFragment(Varyings i) : SV_Target
{
float4 mainTex = i.color * SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
#if defined(DEBUG_DISPLAY)
SurfaceData2D surfaceData;
InputData2D inputData;
half4 debugColor = 0;
InitializeSurfaceData(mainTex.rgb, mainTex.a, surfaceData);
InitializeInputData(i.uv, inputData);
SETUP_DEBUG_DATA_2D(inputData, i.positionWS);
if(CanDebugOverrideOutputColor(surfaceData, inputData, debugColor))
{
return debugColor;
}
#endif
return mainTex;
}
ENDHLSL
}
// Pass 1 - Create alpha mask
Pass
{
Name "BUFFERFILL"
Tags { "LightMode" = "PlayerCharacter" }
Blend SrcAlpha OneMinusSrcAlpha
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
// #pragma target 4.5
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
struct attributes
{
float3 positionOS : POSITION;
float2 uv : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct Varyings
{
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
UNITY_VERTEX_OUTPUT_STEREO
};
TEXTURE2D(_MainTex);
SAMPLER(sampler_MainTex);
half4 _MainTex_ST;
Varyings vert(attributes v)
{
Varyings o;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
o.positionCS = TransformObjectToHClip(v.positionOS);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
half4 frag(Varyings i) : SV_Target
{
float4 mainTex = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
return mainTex.a;
}
ENDHLSL
}
Pass // 2
{
Name "JUMPFLOODINIT"
Tags { "LightMode" = "PlayerCharacter" }
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#pragma target 4.5
struct appdata
{
float3 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 pos : SV_POSITION;
};
Texture2D _MainTex;
float4 _MainTex_TexelSize;
v2f vert (appdata v)
{
v2f o;
o.pos = TransformObjectToHClip(v.vertex);
return o;
}
float2 frag (v2f i) : SV_Target {
// integer pixel position
int2 uvInt = i.pos.xy;
// sample silhouette texture for sobel
half3x3 values;
UNITY_UNROLL
for(int u=0; u<3; u++)
{
UNITY_UNROLL
for(int v=0; v<3; v++)
{
uint2 sampleUV = clamp(uvInt + int2(u-1, v-1), int2(0,0), (int2)_MainTex_TexelSize.zw - 1);
values[u][v] = _MainTex.Load(int3(sampleUV, 0)).r;
}
}
// calculate output position for this pixel
float2 outPos = i.pos.xy * abs(_MainTex_TexelSize.xy) * FLOOD_ENCODE_SCALE - FLOOD_ENCODE_OFFSET;
// interior, return position
if (values._m11 > 0.99)
return FLOOD_NULL_POS_FLOAT2;
// exterior, return no position
if (values._m11 < 0.01)
return FLOOD_NULL_POS_FLOAT2;
// sobel to estimate edge direction
float2 dir = -float2(
values[0][0] + values[0][1] * 2.0 + values[0][2] - values[2][0] - values[2][1] * 2.0 - values[2][2],
values[0][0] + values[1][0] * 2.0 + values[2][0] - values[0][2] - values[1][2] * 2.0 - values[2][2]
);
// if dir length is small, this is either a sub pixel dot or line
// no way to estimate sub pixel edge, so output position
if (abs(dir.x) <= 0.005 && abs(dir.y) <= 0.005)
return outPos;
// normalize direction
dir = normalize(dir);
// sub pixel offset
float2 offset = dir * (1.0 - values._m11);
// output encoded offset position
return (i.pos.xy + offset) * abs(_MainTex_TexelSize.xy) * FLOOD_ENCODE_SCALE - FLOOD_ENCODE_OFFSET;
}
ENDHLSL
}
Pass // 3
{
Name "JUMPFLOOD_SINGLEAXIS"
Tags { "LightMode" = "PlayerCharacter" }
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#pragma target 4.5
struct appdata
{
float3 vertex : POSITION;
};
struct v2f
{
float4 pos : SV_POSITION;
};
Texture2D _MainTex;
float4 _MainTex_TexelSize;
int2 _AxisWidth;
v2f vert (appdata v)
{
v2f o;
o.pos = TransformObjectToHClip(v.vertex);
return o;
}
half2 frag (v2f i) : SV_Target {
// integer pixel position
int2 uvInt = int2(i.pos.xy);
// initialize best distance at infinity
float bestDist = 1.#INF;
float2 bestCoord;
// jump samples
// only one loop
UNITY_UNROLL
for(int u=-1; u<=1; u++)
{
// calculate offset sample position
int2 offsetUV = uvInt + _AxisWidth * u;
// .Load() acts funny when sampling outside of bounds, so don't
offsetUV = clamp(offsetUV, int2(0,0), (int2)_MainTex_TexelSize.zw - 1);
// decode position from buffer
float2 offsetPos = (_MainTex.Load(int3(offsetUV, 0)).rg + FLOOD_ENCODE_OFFSET) * _MainTex_TexelSize.zw / FLOOD_ENCODE_SCALE;
// the offset from current position
float2 disp = i.pos.xy - offsetPos;
// square distance
float dist = dot(disp, disp);
// if offset position isn't a null position or is closer than the best
// set as the new best and store the position
if (offsetPos.x != -1.0 && dist < bestDist)
{
bestDist = dist;
bestCoord = offsetPos;
}
}
// if not valid best distance output null position, otherwise output encoded position
return isinf(bestDist) ? FLOOD_NULL_POS_FLOAT2 : bestCoord * _MainTex_TexelSize.xy * FLOOD_ENCODE_SCALE - FLOOD_ENCODE_OFFSET;
}
ENDHLSL
}
Pass // 4
{
Name "JUMPFLOODOUTLINE"
Tags { "LightMode" = "PlayerCharacter" }
// Stencil {
// Ref 1
// ReadMask 1
// WriteMask 1
// Comp NotEqual
// Pass Zero
// Fail Zero
// }
Blend SrcAlpha OneMinusSrcAlpha
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#pragma target 4.5
struct appdata
{
float3 vertex : POSITION;
};
struct v2f
{
float4 pos : SV_POSITION;
};
Texture2D _MainTex;
Texture2D _OutlineMaskBuffer;
half4 _OutlineColor;
float _OutlineWidth;
v2f vert (appdata v)
{
v2f o;
o.pos = TransformObjectToHClip(v.vertex);
return o;
}
half4 frag (v2f i) : SV_Target {
// integer pixel position
int2 uvInt = int2(i.pos.xy);
// load encoded position
float2 encodedPos = _MainTex.Load(int3(uvInt, 0)).rg;
// early out if null position
if (encodedPos.y == -1)
return half4(0,0,0,0);
// decode closest position
float2 nearestPos = (encodedPos + FLOOD_ENCODE_OFFSET) * abs(_ScreenParams.xy) / FLOOD_ENCODE_SCALE;
// current pixel position
float2 currentPos = i.pos.xy;
// distance in pixels to closest position
half dist = length(nearestPos - currentPos);
// calculate outline
// + 1.0 is because encoded nearest position is half a pixel inset
// not + 0.5 because we want the anti-aliased edge to be aligned between pixels
// distance is already in pixels, so this is already perfectly anti-aliased!
half outline = saturate(_OutlineWidth - dist + 1.0);
// apply outline to alpha
half4 col = _OutlineColor;
col.a *= outline;
col.a *= 1-_OutlineMaskBuffer.Load(int3(uvInt, 0)).r;
// profit!
return col;
}
ENDHLSL
}
}
}
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Experimental.Rendering;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
namespace Engine.Rendering
{
public enum JFAOutlinePass
{
SpriteRender = 0,
AlphaMask = 1,
Init = 2,
Flood = 3,
Outline = 4
}
public class OutlineRendererFeature : ScriptableRendererFeature
{
private BufferPopulatePass _populatePass;
[SerializeField] private Material outlineMaterial;
[SerializeField] private LayerMask targetLayers;
[SerializeField] private float outlinePixelWidth = 2f;
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
renderer.EnqueuePass(_populatePass);
}
public override void Create()
{
_populatePass = new BufferPopulatePass(outlineMaterial, targetLayers, outlinePixelWidth);
_populatePass.renderPassEvent = RenderPassEvent.BeforeRenderingTransparents;
}
}
public class BufferPopulatePass : ScriptableRenderPass
{
private static readonly int MaskBufferID = Shader.PropertyToID("_OutlineMaskBuffer");
private static readonly int JumpFloodBufferPingID = Shader.PropertyToID("_JFBPing");
private static readonly int JumpFloodBufferPongID = Shader.PropertyToID("_JFBPong");
private static readonly int StepWidthID = Shader.PropertyToID("_StepWidth");
private static readonly int AxisWidthID = Shader.PropertyToID("_AxisWidth");
private readonly Material _outlineMaterial;
private readonly float _outlinePixelWidth;
private FilteringSettings _filteringSettings;
private readonly List<ShaderTagId> _tags;
private readonly LayerMask _layerMask;
private readonly ProfilingSampler _profilingSampler;
public BufferPopulatePass(Material outlineMaterial, LayerMask targetLayers, float outlinePixelWidth)
{
_outlineMaterial = outlineMaterial;
_filteringSettings = new FilteringSettings(RenderQueueRange.transparent, targetLayers);
_layerMask = targetLayers;
_tags = new List<ShaderTagId>();
_tags.Add(new ShaderTagId("PlayerCharacter"));
_outlinePixelWidth = outlinePixelWidth;
_profilingSampler = new ProfilingSampler("Buffer Populate Pass");
}
public override void Configure(CommandBuffer cb, RenderTextureDescriptor cameraTextureDescriptor)
{
var rtd = new RenderTextureDescriptor(
cameraTextureDescriptor.width,
cameraTextureDescriptor.height,
GraphicsFormat.R8_UNorm,
0,
0
);
cb.GetTemporaryRT(MaskBufferID, rtd, FilterMode.Point);
var rtd2 = new RenderTextureDescriptor(
cameraTextureDescriptor.width,
cameraTextureDescriptor.height,
GraphicsFormat.R16G16_SNorm,
0,
0
);
cb.GetTemporaryRT(JumpFloodBufferPingID, rtd2, FilterMode.Point);
cb.GetTemporaryRT(JumpFloodBufferPongID, rtd2, FilterMode.Point);
ConfigureTarget(MaskBufferID);
ConfigureClear(ClearFlag.Color, Color.clear);
}
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
var drawingSettings = CreateDrawingSettings(_tags, ref renderingData, SortingCriteria.CommonTransparent);
drawingSettings.enableDynamicBatching = true;
drawingSettings.SetShaderPassName((int) JFAOutlinePass.AlphaMask, new ShaderTagId("BUFFERFILL"));
context.DrawRenderers(renderingData.cullResults, ref drawingSettings, ref _filteringSettings);
// Alan Wolfe's separable axis JFA - https://www.shadertoy.com/view/Mdy3D3
var numMips = Mathf.CeilToInt(Mathf.Log(_outlinePixelWidth + 1.0f, 2f));
var cmd = CommandBufferPool.Get();
using (new ProfilingScope(cmd, _profilingSampler))
{
cmd.Blit(MaskBufferID, JumpFloodBufferPingID, _outlineMaterial, 2);
for (int i = numMips - 1; i >= 0; i--)
{
// calculate appropriate jump width for each iteration
// + 0.5 is just me being cautious to avoid any floating point math rounding errors
float stepWidth = Mathf.Pow(2, i) + 0.5f;
// the two separable passes, one axis at a time
cmd.SetGlobalVector(AxisWidthID, new Vector2(stepWidth, 0f));
cmd.Blit(JumpFloodBufferPingID, JumpFloodBufferPongID, _outlineMaterial, (int)JFAOutlinePass.Flood);
cmd.SetGlobalVector(AxisWidthID, new Vector2(0f, stepWidth));
cmd.Blit(JumpFloodBufferPongID, JumpFloodBufferPingID, _outlineMaterial, (int)JFAOutlinePass.Flood);
}
cmd.SetGlobalTexture(MaskBufferID, MaskBufferID);
cmd.Blit(JumpFloodBufferPingID, renderingData.cameraData.renderer.cameraColorTarget, _outlineMaterial,
(int)JFAOutlinePass.Outline);
}
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}
public override void OnCameraCleanup(CommandBuffer cmd)
{
cmd.ReleaseTemporaryRT(MaskBufferID);
cmd.ReleaseTemporaryRT(JumpFloodBufferPingID);
cmd.ReleaseTemporaryRT(JumpFloodBufferPongID);
}
}
}
@SudoCat
Copy link
Author

SudoCat commented Dec 16, 2022

This takes BGolus' original approach and updates it to function in URP. This is heavily tailored towards 2D multi-part sprites, and likely leaves a whole lot of room for improvement - however I have never used any of Unity's Graphics APIs before, so I'm far from an expert.

It also only implements Alan Wolfe's JFA, rather than the trad JFA as well.

Please feel free to extend, or if you have any suggestions of how it could be better, please let me know! ♥

@alijaya
Copy link

alijaya commented Dec 21, 2022

hello, how to use this?

@huge-sesh
Copy link

huge-sesh commented Jan 21, 2023

Hey, this is great!

For those wondering how to use this:

  • Add a rendering pass to your URP renderer asset, using the included script. Make sure "Default" is one of the layers it is targeting.
  • Create a material that uses this shader.
  • Assign the material to an object in your scene. Make sure a texture is assigned to the shader - one with transparencies.

@SudoCat - could you possibly release under some kind of license? I'm nervous to include this in my work with it being unlicensed.

@SudoCat
Copy link
Author

SudoCat commented Jan 29, 2023

sure thing, it's still kind of buggy tho! I need to iron out some issues with small outline widths when I get time. I wouldn't recommend using this code as-is.

I'm honestly not sure what I'd need to do to license it? Do I need to add a file with a license, or will me commenting stating "Released under WTFPL" do the trick?

Because, yeah, it's definitely WTFPL

EDIT: should note tho, that I've based all of this code on BGolus' work, so I can't strictly claim it as my own. maybe muddies the licensing waters? I'm really not accustomed to licensing things, sorry.

@huge-sesh
Copy link

huge-sesh commented Feb 11, 2023

sure thing, it's still kind of buggy tho! I need to iron out some issues with small outline widths when I get time. I wouldn't recommend using this code as-is.

I'm honestly not sure what I'd need to do to license it? Do I need to add a file with a license, or will me commenting stating "Released under WTFPL" do the trick?

Because, yeah, it's definitely WTFPL

EDIT: should note tho, that I've based all of this code on BGolus' work, so I can't strictly claim it as my own. maybe muddies the licensing waters? I'm really not accustomed to licensing things, sorry.

Ideally with a full repo there's first class support for a LICENSE file. I think the preferred thing to do with a gist is to just put the license in a comment at the top of every file–or at least I feel more comfortable with that approach.

My guess is that adding a free license at least frees the code from your own claims on it (though yes, there might be other claims higher up the chain). I'm not a lawyer, this is not legal advice.

Anyways, I ended up writing the whole thing from scratch anyways since my use case is a bit different and I needed to actually understand what was happening in the shader (it's the first postprocessing effect i've ever written).

@mistyskye
Copy link

Hi, this is really promising. You mention your fork is tailored to 2D sprites, but I'm interested in using this URP version for 3D object outlines.
Do you know how I could go about making this work?

@Renardjojo
Copy link

Thanks for sharing!
There is however a critical error in this shader line 223.
You should return "return outPos;", not return FLOOD_NULL_POS_FLOAT2 ;

@SudoCat
Copy link
Author

SudoCat commented Apr 25, 2023

Hi, this is really promising. You mention your fork is tailored to 2D sprites, but I'm interested in using this URP version for 3D object outlines. Do you know how I could go about making this work?

Not a clue, I'm afraid! I'm not the most experienced myself. Probably somewhere between BGolus' and what I've done.

Thanks for sharing! There is however a critical error in this shader line 223. You should return "return outPos;", not return FLOOD_NULL_POS_FLOAT2 ;

Thanks! That probably explains some of the odd behaviour I've seen 😅 (edit: apparently i fixed that locally at some point and never updated the gist)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment