using System; | |
using System.Collections; | |
using System.Collections.Generic; | |
using UnityEngine; | |
using UnityEngine.Rendering; | |
using UnityEngine.Experimental.Rendering; | |
#if UNITY_EDITOR | |
using UnityEditor; | |
#endif | |
[ExecuteInEditMode] | |
public class JumpFloodOutlineRenderer : MonoBehaviour | |
{ | |
[ColorUsageAttribute(true, true)] public Color outlineColor = Color.white; | |
[Range(0.0f, 1000.0f)] public float outlinePixelWidth = 4f; | |
// list of all renderer components you want to have outlined as a single silhouette | |
public List<Renderer> renderers = new List<Renderer>(); | |
// hidden reference to ensure shader gets included with builds | |
// gets auto-assigned with an OnValidate() function later | |
[HideInInspector, SerializeField] private Shader outlineShader; | |
// some hidden settings | |
const string shaderName = "Hidden/JumpFloodOutline"; | |
const CameraEvent cameraEvent = CameraEvent.AfterForwardAlpha; | |
const bool useSeparableAxisMethod = true; | |
// shader pass indices | |
const int SHADER_PASS_INTERIOR_STENCIL = 0; | |
const int SHADER_PASS_SILHOUETTE_BUFFER_FILL = 1; | |
const int SHADER_PASS_JFA_INIT = 2; | |
const int SHADER_PASS_JFA_FLOOD = 3; | |
const int SHADER_PASS_JFA_FLOOD_SINGLE_AXIS = 4; | |
const int SHADER_PASS_JFA_OUTLINE = 5; | |
// render texture IDs | |
private int silhouetteBufferID = Shader.PropertyToID("_SilhouetteBuffer"); | |
private int nearestPointID = Shader.PropertyToID("_NearestPoint"); | |
private int nearestPointPingPongID = Shader.PropertyToID("_NearestPointPingPong"); | |
// shader properties | |
private int outlineColorID = Shader.PropertyToID("_OutlineColor"); | |
private int outlineWidthID = Shader.PropertyToID("_OutlineWidth"); | |
private int stepWidthID = Shader.PropertyToID("_StepWidth"); | |
private int axisWidthID = Shader.PropertyToID("_AxisWidth"); | |
// private variables | |
private CommandBuffer cb; | |
private Material outlineMat; | |
private Camera bufferCam; | |
private Mesh MeshFromRenderer(Renderer r) | |
{ | |
if (r is SkinnedMeshRenderer) | |
return (r as SkinnedMeshRenderer).sharedMesh; | |
else if (r is MeshRenderer) | |
return r.GetComponent<MeshFilter>().sharedMesh; | |
return null; | |
} | |
private void CreateCommandBuffer(Camera cam) | |
{ | |
if (renderers == null || renderers.Count == 0) | |
return; | |
if (cb == null) | |
{ | |
cb = new CommandBuffer(); | |
cb.name = "JumpFloodOutlineRenderer: " + gameObject.name; | |
} | |
else | |
{ | |
cb.Clear(); | |
} | |
if (outlineMat == null) | |
{ | |
outlineMat = new Material(outlineShader != null ? outlineShader : Shader.Find(shaderName)); | |
} | |
// do nothing if no outline will be visible | |
if (outlineColor.a <= (1f/255f) || outlinePixelWidth <= 0f) | |
{ | |
cb.Clear(); | |
return; | |
} | |
// support meshes with sub meshes | |
// can be from having multiple materials, complex skinning rigs, or a lot of vertices | |
int renderersCount = renderers.Count; | |
int[] subMeshCount = new int[renderersCount]; | |
for (int i=0; i<renderersCount; i++) | |
{ | |
var mesh = MeshFromRenderer(renderers[i]); | |
Debug.Assert(mesh != null, "JumpFloodOutlineRenderer's renderer [" + i + "] is missing a valid mesh.", gameObject); | |
if (mesh != null) | |
{ | |
// assume staticly batched meshes only have one sub mesh | |
if (renderers[i].isPartOfStaticBatch) | |
subMeshCount[i] = 1; // hack hack hack | |
else | |
subMeshCount[i] = mesh.subMeshCount; | |
} | |
} | |
// render meshes to main buffer for the interior stencil mask | |
cb.SetRenderTarget(BuiltinRenderTextureType.CameraTarget); | |
for (int i=0; i<renderersCount; i++) | |
{ | |
for (int m = 0; m < subMeshCount[i]; m++) | |
cb.DrawRenderer(renderers[i], outlineMat, m, SHADER_PASS_INTERIOR_STENCIL); | |
} | |
// match current quality settings' MSAA settings | |
// doesn't check if current camera has MSAA enabled | |
// also could just always do MSAA if you so pleased | |
int msaa = Mathf.Max(1,QualitySettings.antiAliasing); | |
int width = cam.scaledPixelWidth; | |
int height = cam.scaledPixelHeight; | |
// setup descriptor for silhouette render texture | |
RenderTextureDescriptor silhouetteRTD = new RenderTextureDescriptor() { | |
dimension = TextureDimension.Tex2D, | |
graphicsFormat = GraphicsFormat.R8_UNorm, | |
width = width, | |
height = height, | |
msaaSamples = msaa, | |
depthBufferBits = 0, | |
sRGB = false, | |
useMipMap = false, | |
autoGenerateMips = false | |
}; | |
// create silhouette buffer and assign it as the current render target | |
cb.GetTemporaryRT(silhouetteBufferID, silhouetteRTD, FilterMode.Point); | |
cb.SetRenderTarget(silhouetteBufferID); | |
cb.ClearRenderTarget(false, true, Color.clear); | |
// render meshes to silhouette buffer | |
for (int i=0; i<renderersCount; i++) | |
{ | |
for (int m = 0; m < subMeshCount[i]; m++) | |
cb.DrawRenderer(renderers[i], outlineMat, m, SHADER_PASS_SILHOUETTE_BUFFER_FILL); | |
} | |
// Humus3D wire trick, keep line 1 pixel wide and fade alpha instead of making line smaller | |
// slightly nicer looking and no more expensive | |
Color adjustedOutlineColor = outlineColor; | |
adjustedOutlineColor.a *= Mathf.Clamp01(outlinePixelWidth); | |
cb.SetGlobalColor(outlineColorID, adjustedOutlineColor.linear); | |
cb.SetGlobalFloat(outlineWidthID, Mathf.Max(1f, outlinePixelWidth)); | |
// setup descriptor for jump flood render textures | |
var jfaRTD = silhouetteRTD; | |
jfaRTD.msaaSamples = 1; | |
jfaRTD.graphicsFormat = GraphicsFormat.R16G16_SNorm; | |
// create jump flood buffers to ping pong between | |
cb.GetTemporaryRT(nearestPointID, jfaRTD, FilterMode.Point); | |
cb.GetTemporaryRT(nearestPointPingPongID, jfaRTD, FilterMode.Point); | |
// calculate the number of jump flood passes needed for the current outline width | |
// + 1.0f to handle half pixel inset of the init pass and antialiasing | |
int numMips = Mathf.CeilToInt(Mathf.Log(outlinePixelWidth + 1.0f, 2f)); | |
int jfaIter = numMips-1; | |
// Alan Wolfe's separable axis JFA - https://www.shadertoy.com/view/Mdy3D3 | |
if (useSeparableAxisMethod) | |
{ | |
// jfa init | |
cb.Blit(silhouetteBufferID, nearestPointID, outlineMat, SHADER_PASS_JFA_INIT); | |
// jfa flood passes | |
for (int i=jfaIter; 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 | |
cb.SetGlobalVector(axisWidthID, new Vector2(stepWidth, 0f)); | |
cb.Blit(nearestPointID, nearestPointPingPongID, outlineMat, SHADER_PASS_JFA_FLOOD_SINGLE_AXIS); | |
cb.SetGlobalVector(axisWidthID, new Vector2(0f, stepWidth)); | |
cb.Blit(nearestPointPingPongID, nearestPointID, outlineMat, SHADER_PASS_JFA_FLOOD_SINGLE_AXIS); | |
} | |
} | |
// traditional JFA | |
else | |
{ | |
// choose a starting buffer so we always finish on the same buffer | |
int startBufferID = (jfaIter % 2 == 0) ? nearestPointPingPongID : nearestPointID; | |
// jfa init | |
cb.Blit(silhouetteBufferID, startBufferID, outlineMat, SHADER_PASS_JFA_INIT); | |
// jfa flood passes | |
for (int i=jfaIter; 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 | |
cb.SetGlobalFloat(stepWidthID, Mathf.Pow(2, i) + 0.5f); | |
// ping pong between buffers | |
if (i % 2 == 1) | |
cb.Blit(nearestPointID, nearestPointPingPongID, outlineMat, SHADER_PASS_JFA_FLOOD); | |
else | |
cb.Blit(nearestPointPingPongID, nearestPointID, outlineMat, SHADER_PASS_JFA_FLOOD); | |
} | |
} | |
// jfa decode & outline render | |
cb.Blit(nearestPointID, BuiltinRenderTextureType.CameraTarget, outlineMat, SHADER_PASS_JFA_OUTLINE); | |
cb.ReleaseTemporaryRT(silhouetteBufferID); | |
cb.ReleaseTemporaryRT(nearestPointID); | |
cb.ReleaseTemporaryRT(nearestPointPingPongID); | |
} | |
void ApplyCommandBuffer(Camera cam) | |
{ | |
#if UNITY_EDITOR | |
// hack to avoid rendering in the inspector preview window | |
if (cam.gameObject.name == "Preview Scene Camera") | |
return; | |
#endif | |
if (bufferCam != null) | |
{ | |
if(bufferCam == cam) | |
return; | |
else | |
RemoveCommandBuffer(cam); | |
} | |
Plane[] planes = GeometryUtility.CalculateFrustumPlanes(cam); | |
// skip rendering if none of the renderers are in view | |
bool visible = false; | |
for (int i=0; i<renderers.Count; i++) | |
{ | |
if (GeometryUtility.TestPlanesAABB(planes, renderers[i].bounds)) | |
{ | |
visible = true; | |
break; | |
} | |
} | |
if (!visible) | |
return; | |
CreateCommandBuffer(cam); | |
if (cb == null) | |
return; | |
bufferCam = cam; | |
bufferCam.AddCommandBuffer(cameraEvent, cb); | |
} | |
void RemoveCommandBuffer(Camera cam) | |
{ | |
if (bufferCam != null && cb != null) | |
{ | |
bufferCam.RemoveCommandBuffer(cameraEvent, cb); | |
bufferCam = null; | |
} | |
} | |
void OnEnable() | |
{ | |
Camera.onPreRender += ApplyCommandBuffer; | |
Camera.onPostRender += RemoveCommandBuffer; | |
} | |
void OnDisable() | |
{ | |
Camera.onPreRender -= ApplyCommandBuffer; | |
Camera.onPostRender -= RemoveCommandBuffer; | |
} | |
#if UNITY_EDITOR | |
void OnValidate() | |
{ | |
if (renderers != null) | |
{ | |
for (int i=renderers.Count-1; i>-1; i--) | |
{ | |
if (renderers[i] == null || (!(renderers[i] is SkinnedMeshRenderer) && !(renderers[i] is MeshRenderer))) | |
renderers.RemoveAt(i); | |
else | |
{ | |
bool foundDuplicate = false; | |
for (int k=0; k<i; k++) | |
{ | |
if (renderers[i] == renderers[k]) | |
{ | |
foundDuplicate = true; | |
break; | |
} | |
} | |
if (foundDuplicate) | |
renderers.RemoveAt(i); | |
} | |
} | |
} | |
if (outlineShader == null) | |
outlineShader = Shader.Find(shaderName); | |
} | |
public void FindActiveMeshes() | |
{ | |
Undo.RecordObject(this, "Filling with all active Renderer components"); | |
GameObject parent = this.gameObject; | |
if (renderers != null) | |
{ | |
foreach (var renderer in renderers) | |
{ | |
if (renderer) | |
{ | |
parent = renderer.transform.parent.gameObject; | |
break; | |
} | |
} | |
} | |
if (parent != null) | |
{ | |
var skinnedMeshes = parent.GetComponentsInChildren<SkinnedMeshRenderer>(true); | |
var meshes = parent.GetComponentsInChildren<MeshRenderer>(true); | |
if (skinnedMeshes.Length > 0 || meshes.Length > 0) | |
{ | |
foreach (var sk in skinnedMeshes) | |
{ | |
if (sk.gameObject.activeSelf) | |
renderers.Add(sk); | |
} | |
foreach (var mesh in meshes) | |
{ | |
if (mesh.gameObject.activeSelf) | |
renderers.Add(mesh); | |
} | |
OnValidate(); | |
} | |
else | |
Debug.LogError("No Active Meshes Found"); | |
} | |
} | |
#endif | |
} | |
#if UNITY_EDITOR | |
[CustomEditor(typeof(JumpFloodOutlineRenderer))] | |
public class JumpFloodOutlineRendererEditor : Editor | |
{ | |
public override void OnInspectorGUI() | |
{ | |
base.OnInspectorGUI(); | |
if (GUILayout.Button("Get Active Children Renderers")) | |
{ | |
UnityEngine.Object[] objs = serializedObject.targetObjects; | |
foreach (var obj in objs) | |
{ | |
var mh = (obj as JumpFloodOutlineRenderer); | |
mh.FindActiveMeshes(); | |
} | |
} | |
} | |
} | |
#endif |
Yes. But that's outside the scope of this code example.
There are two main things that'd need to be changed to support sprite renderers.
The first thing is the current method of rendering the silhouette buffer is assuming an opaque material. For sprites you'd have to add a shader that supports transparency from a texture. I'm also not sure if using the DrawRenderer
function to draw a SpriteRenderer
automatically gets the appropriate texture assigned to the passed in material. AFAIK SpriteRenderer
components use a MaterialPropertyBlock
to assign the correct sprite texture to the material, which should apply to any DrawRenderer
command. If so it should be as "easy" as extending the above code to accept SpriteRenderer
components, adding an extra pass to the shader that supports transparency, and rendering the sprite with that pass. But I have never intentionally used sprites renderers for anything so I don't know.
The second issue is the JFA approach explicitly only works on hard edges. One of the magic bits of the example code above is that it supports anti-aliasing which normally it would not. But it also assumes any anti-aliased edge is no more than 1 pixel. Anything more than that and the edge estimates won't behave, so a wide soft edge will totally fail. This means the pass added to support transparency needs to apply some kind of sharpening. For example something like what I proposed in my Alpha to Coverage article.
https://medium.com/@bgolus/anti-aliased-alpha-test-the-esoteric-alpha-to-coverage-8b177335ae4f
Similarly the compositing back into the scene also assumes a hard edge, as it uses a stencil to handle this. So technically you'd need to add at least two passes to the above shader with sharpened transparency texture support, one for the silhouette, and one for the stencil. If your sprites use the same kind of sharpening and alpha to coverage the outline will composite properly with them. Otherwise it's basically impossible to composite correctly with a soft alpha blended edge without significantly changing the approach used to render the outlines back into the scene. It'd have to be a quad placed in the scene that the final outline gets rendered on instead of as a blit at the end.
Is there a license for this?
The code is primarily provided for people to learn from it. However, my other educational projects I’ve put up I’ve released under Unlicense. So I guess, consider this code under that same license.
https://unlicense.org/
Basically, it’s released unto the public domain, do what you want with it. Feel free to credit me or not. I know some companies require more specific licenses, like Apache, GNU GPL, MIT, etc. But those all come with additional requirements I have no interest in saddling this code with.
Great write-up, Ben! Thanks for putting this together.
As-is silhouettes "stain" the buffer once drawn in so if the renderers move the silhouettes smear towards the new position. I was able to solve this by adding a call to ClearRenderTarget on the silhouette buffer after line 144.
Hey, good catch! I've updated the code with that fix. The original version of the script this example is derived from is doing the clear, but I must have removed it on accident when cleaning it up for release. Didn't notice since the test mesh was in a static pose by that point for getting consistent screen shots.
Figured that might be the case. Happy to help!
I've done a URP port of this feature. I've stripped out some of the proprietary elements meaning that this wont run as is, all that's missing is JFOBufferFillPass collecting and blit'ing the meshes to the buffer for the JumpFloodOutlinePass to operate on: https://gist.github.com/CianNoonan/c56256433801991038c9c40a48fe3002
Thanks for this and the amazingly detailed Medium post. I noticed a small problem though that if MSAA is disabled in forward rendering ( 2019.4.x) the outline is flipped vertically due to the UNITY_UV_STARTS_AT_TOP. Not entirely sure but I suspect its to do with the built-in renderers general weirdness and inconsistencies when dealing with MSAA, ImageEffects, RenderToTexture and resolving. I fixed it by adding a Shaderr_Feature to allow/prevent the UNITY_UV_STARTS_AT_TOP flip and that feature is toggled base on if MSAA is enabled on the camera and quality settings. Honestly though i'm not sure there is a 100% reliable means to fix this as its dependant on several factors. So really its just something to try and be aware of.
Thanks for sharing, there was some problems where a small radial section of the screen near UV(0,0) doesn't get covered in the jump flood.
For anyone else with this problem, this was my fix (line 231 and 308):
float2 offsetPos = (_MainTex.Load(int3(offsetUV, 0)).rg + FLOOD_ENCODE_OFFSET) * _MainTex_TexelSize.zw / FLOOD_ENCODE_SCALE;
...
if (offsetPos.x != -1.0 && dist < bestDist)
to
float2 encodedPos = _MainTex.Load(int3(offsetUV, 0)).rg;
float2 offsetPos = (encodedPos + FLOOD_ENCODE_OFFSET) * _MainTex_TexelSize.zw / FLOOD_ENCODE_SCALE;
...
if (encodedPos.y != FLOOD_NULL_POS && dist < bestDist)
Similarly, if you want to render outlines to a texture that is not the camera's active texture, change line 388 '_ScreenParams.xy' to '_MainTex_TexelSize.zw' (you will have to add it as a variable to the pass).
Hope this helps.
Edit: I have made a fork with these changes applied here: https://gist.github.com/mattdevv/ae0c7a0118a2c7d7fd09cd35ae911665
I understand shaders fairly well but have only really written fragment shaders in Godot and used Materials in Unreal Engine - I'm definitely able to follow what's happening line by line, and I'm on mobile right now so I apologize if this is something that would be much more obvious on desktop (where scrolling horizontally AND vertically line by line isn't necessary), but I'm hoping I could get a couple of pointers?
-
There are two files and while that's normal for declaration and implementation approaches.. I'm not familiar with Unity and these files aren't of that style. Having truly not been able to follow this on my phone at 4am my best guess is that one of these files is just a variation and I'm supposed to choose one or the other rather than see it as a two step process.. is that correct?
-
I'm hoping to know how well this scales with multiple characters? The profiling done in the blog post is described based on the thickness of the line but I didn't see any mention of how it scales with each mesh added to the stencil. I did see that in the shader I can send multiple meshes as a group to indicate that I want them to be treated as one object for the outline but that's, I think, the closest I've seen as a mention of what the cost of giving everything in a standard game scene an outline. Say.. oh I dunno, 30 separate "mesh groups" ? Like decently well optimized I'd hope. What about 2000? Like just worst case scenario, crowds of entities, trees, birds, grass, flowers, rain, just.. the works. Feasible? If you don't know it's alright, I'm completely expecting to use inverse hull for anything that I can get away with (:
-
You mentioned compute shaders rather than using a render target / stencil buffer and I'm not sure that I understand why that would help or even how I'd begin working at that - I'd love to give it a shot and share how it went but I'm.. not following. Typically compute shaders are for when neither a texture nor vertices are necessary, and it's primarily meant for number crunching.. this seems like exactly what a render pass in the stencil buffer is meant for.. no?
- There are two files
The first file is Unity's ShaderLab shader code. That includes both declaration and the shader code for multiple separate vertex fragment shader passes. The second file is C# and is the code for setting up the necessary render targets, rendering the meshes, and calling the appropriate pass from the shader file.
- I'm hoping to know how well this scales with multiple characters?
The cost of rendering each mesh is trivial in my example case, as it assumes all meshes are opaque and can use the same shader. The passes that render the individual meshes are a solid white pass and the stencil pass, both of which are very cheap as the fragment shaders for both are effectively a single line. So the cost difference between rendering one mesh or several dozen mostly comes down to the vertex count. Unless you have a lot of meshes covering a large portion of the screen in which case the over shading cost for the initial silhouette rendering pass might become measurable percentage of the overall effect. Understand the numbers in the graphs comparing the different techniques are the time for the entire effect, including all passes. So those numbers include rendering of the silhouette (which all techniques used), as well as any additional stencil passes each technique might be using.
For example, using a single t-posed character with around 10k vertices accounted for less than 100 μs (0.1 ms) of the overall effect for all passes directly rendering that mesh. That means if you're rendering a 1 or 2 pixel wide outline, the rendering of multiple characters might end up being the more significant cost, but for wider outlines the cost of the outline itself will probably still be the larger factor.
Now if you have 30 separate meshes that you want to individually have outlined, the cost will be 30x the numbers listed. If you want to have 30 separate meshes that have a shared outline, the cost probably won't be all that much more than what's listed in the article. That said several mobile games as well as games like League of Legends do use brute force outlines on dozens of characters and don't have a problem (though they're usually either running on sprites so they "skip" the silhouette and stencil rendering parts, or run wide outlines at a reduced resolution as is the case for League of Legends).
- You mentioned compute shaders rather than using a render target / stencil buffer
My quick side comment about using compute shaders is probably a little confusing.
A compute shader would likely be faster at this than the render texture approach I’m currently using. But that’s a task for another day.
By that I meant use a compute shader to do the jump flooding instead of using a vertex fragment shader. I should probably edit that to say "faster than the blit based approach" as it would still involve using render textures and stencils.
First of all, thank you to Ben for sharing the code, and thank you also to everyone who has helped improve it.
Although I've been developing in C# for years, I'm new to Unity and I lack a lot of knowledge to solve a problem that comes to me with this code: when it's compiled and run on Android, the Shader is reflected on the vertical axis. I tried changing the useSeparableAxisMethod parameter but the same thing happens. Maybe it's related to what @noisecrime wrote, but I don't even understand his instructions for fixing it.
I attach two sample videos to show the problem.
Any help will be welcome. Thanks in advance!
On Android:
https://user-images.githubusercontent.com/102323700/162596938-eb80f879-e5ce-447d-af26-28c20bb2af19.mp4
@ezuzito I did some testing on which rendering options gave the correct output (Unity 2020.3.24f1, Samsung S8+). Also I had to swap from GraphicsFormat.R16G16_SNorm
to GraphicsFormat.R32G32_SFloat
when using mobile OpenGLES3 as my phone did not support that texture type.
MSAA | DirectX 11 | OpenGLES3 | Vulkan | Mobile OpenGLES3 | Mobile Vulkan |
---|---|---|---|---|---|
No UV flipping | |||||
Flip if UV starts at top |
So it the current version worked everywhere but on mobile with Vulkan. Is this the platform you were using? Changing HiddenJumpFloodOutline.shader line 78 to: #if (UNITY_UV_STARTS_AT_TOP && !(SHADER_API_MOBILE && SHADER_API_VULKAN))
fixes the rendering for me.
I don't know enough about Vulkan and mobile rendering to say why this problem occurs. Maybe Unity enables the compilation flag UNITY_UV_STARTS_AT_TOP
whenever Vulkan is used despite Vulkan using OpenGL-like coordinates on mobile?
EDIT: I have created a fork of this gist with all the changes I made here: https://gist.github.com/mattdevv/ae0c7a0118a2c7d7fd09cd35ae911665
@mattdevv, your code worked! Thanks a lot for your quick response!
For the record: I was having the issue with an OnePlus 8T (KB2005). I've just had to replace the master code with your branch, build and run.
Great work!
Any way to get this approach to work with SpriteRenderers?