A Look at Spyro’s Portals – Outline Effect, Extra Stuff & Fixes

This is a continuation of the previous post: A Look at Spyro’s Portal – Part 2

Final version of portal shader

Fixing some more issues

When we zoom out the camera, the distortion clips causing holes to appear in the portal; this is because at distance the distortion effect becomes stronger.

I’ve been quite ill recently, thus why the last part has been missing, but while experimenting with the shader I noticed that there is a slight issue with our shader; when we move the camera away from our portal, we will notice that “holes” appear in the portal. This is because we are directly modifying the UVs that are used by grab pass, and with distance the effect becomes stronger thus our plane is actually clipping in front of our stencil cutout and it’s not showing up.

There is a very simple fix that we can use for this issue; as we move further away from the portal we can scale the distortion effect down so the clipping doesn’t happen, but to make sure our effect doesn’t go into negative values we can use saturate() function, which works like clamp, to make sure our distortion value is between zero and one, where at zero the distortion effect won’t be applied at all and at one the effect will be fully applied.

So in our shader after the line where we declare our float4 grabPassUV, we want to declare a float called “fade” and we want to use a function called fwidth() and pass in the grabPassUV into it and we want to multiply it by a distance value; I’ve exposed this value to the editor so I can edit it on the go, and I’ve found that 25 is a good value.

If we pass the fade value straight into the albedo, you will notice that as we move the camera away the portal will fade from black to white, and essentially this is how the distortion effect will fade away.

float4 grabPassUV = IN.grabUV;
float fade = fwidth(grabPassUV) * _FadeDistance;
As we move our camera further away from the portal, the colour changes from black to white; we want the reverse of this.

Next we want to flip this effect, so that when we’re close it will turn white and as we move away from it, it will turn darker and eventually black; in order to do this, we can just add “1 -” in front of the calculation and this will invert the effect.

Finally as mentioned before, we want to make sure that our fade goes from zero and one, in order to do this we want to use the saturate() function and as the parameter we will pass in our calculation but without the minus one.

Finally we want to actually use our fade value and we will use it in the next lane where we calculate the xy values of grabPassUV we want to multiply our calculation by fade value and now as we move away from our portal the distortion effect won’t be as strong as when up close which will stop it from clipping outside our cutout.

float4 grabPassUV = IN.grabUV;
float fade = 1 - saturate(fwidth(grabPassUV) * _FadeDistance);
grabPassUV.xy += combinendDistortion * IN.grabUV * fade;

Adding Outline Effect

Portal outline in Spyro Reignited Trilogy

Now, let’s add one of the final effects to our shader which is the outline around our portal; in order to create this effect I had to carry out some research and the final version uses a slight variation of Makin’ Stuff Look Good’s Winston’s Barrier shader (link). In order to create the outline effect, first we need to make the depth texture available for our shader, in order to do this we’ll create a small C# script and attach it to the camera.

using UnityEngine;
public class CameraDepth : MonoBehaviour
    private Camera cam;
    private void OnEnable()
        cam = GetComponent<Camera>();
        cam.depthTextureMode = DepthTextureMode.DepthNormals;

Because this script will sit on the camera, we don’t need to expose the camera variable, and inside of the OnEnable() function we just grab the camera component and we set the depth texture mode to store depth normals (it includes the depth normals and depth texture, which is what we are using for our shader).

Next, if we actually want to use this texture we need to create a new sampler2D called _CameraDepthTexture, the name has to be exactly like this otherwise our shader won’t recognise the texture. 

Now that we have access to our depth texture, we can actually begin working on the outline effect. First we need the screen position which will be used to sample the depth texture, so we’ll need to add a new float4 screenPos to our Input struct, and inside of vert function we can calculate it using ComputeScreenPos() function and using the o.vertex as parameter.

struct Input
    float4 screenPos;
    float4 worldPos; // we will use it later.
void vert (inout appdata_Full v, out Input o)
    o.screenPos = ComputeScreenPos(o.vertex);

In order to use our depth we need to use a Unity macro called SAMPLE_DEPTH_TEXTURE_PROJ( ), and as the name suggests this macro allows us to sample the depth texture from our camera; the reason we are using a macro is because on some devices the texture sample process can be different, and this macro takes care of all the work for us.

As the parameters we want to use our _CameraDepthTexture, and our screenPos variable but because the macro uses projective divide we need to use another Unity macro called UNITY_PROJ_COORD( ), I described this macro in previous post, and as the parameter we want to pass in our screenPos. Finally we want to wrap this in LinearEyeDepth( ) function, because we want to convert it into world scaled view space depth (depth texture’s 0.0 will become far plane, and 1.0 will become near clip plane).

Next we get the surface depth, and we calculate the difference by taking away surface depth from scene depth. Finally we create a new float called intersect, and if our difference is more than zero, we calculate our intersect.

void surf (Input IN, inout SurfaceOutput o)
    float sceneDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(IN.screenPos)));
    float surfaceDepth = -mul(UNITY_MATRIX_V, float4(IN.worldPos.xyz, 1)).z;
    float difference = sceneDepth - surfaceDepth;
    float intersect = 0; // has to be declared outside if statement
    if (difference > 0)
        intersect = 1 - saturate(difference / _OutlineThreshold<ax);
    float4 intersectColor = intersect * _OutlineStrength * _OutlineColor;

We can create a float4 called intersectColor and multiply our intersect value by intersect strength and multiply that by a color. Finally we want to lerp between our grabPassTexture, intersectColor and pow() function with intersect and 4.0 as parameters. Now if we set our albedo to the .rgb result of the lerp function we will see our inner portal with our grabPassTexture and the intersection going around the edge; you might have to experiment with values to achieve the desired effect.

fixed4 finalColor = fixed4(lerp(grabPassTexture, intersectColor, pow(intersect, 4.0)));
o.Albedo = finalColor.rgb;

Finally we want to position our quads properly so that the effect is displayed. The PortalBlocker should be placed 1 unit behind the quad that displays our final effect, and the stencil cutout quad should be .5 of a unit in front of the final effect quad. The skybox I’ve placed directly in the center of the portal and I made it 25 units big.

Final Steps

The steps you’ll see here are completely optional, we’ve created the main part of the portal and you are free to go and explore the shader! If you would like to recreate the same effect I had in the first post, stick around for more.

I’ve noticed that the Spyro portal isn’t affected by lighting, for this reason we can create our own lighting function called NoLighting, and inside of it we set the values to equal to the values passed in the surface output, this way the lighting won’t affect our shader.

#pragma surface surf Standard vertex:vert
#pragma surface surf NoLighting noshadow vertex:vert
fixed4 LightingNoLighting(SurfaceOutput s, fixed3 lightDir, fixed atten)
    fixed4 c;
    c.rgb = s.Albedo;
    c.a = s.Alpha;
    return c;

By doing this I’ve noticed that our inner portal is now very bright, sometimes too bright, to mitigate this I’ve created a new property called _Color and I’ve multiplied our grabPassTexture by the new value; this way we can now change the colour of our portal and we can “dim” it if it happens to be too bright.

fixed4 finalColor = fixed4(lerp(grabPassTexture, intersectColor, pow(intersect, 4.0)));
fixed4 finalColor = fixed4(lerp(grabPassTexture * _Color, intersectColor, pow(intersect, 4.0)));

If you add this custom lighting function, remember that in our surf function we can no longer use the inout SurfaceOutputStandard as we aren’t using standard lighting models and instead we need to use SurfaceOutput.

Portal effect with point light added in the middle.

Next I’ve noticed that the portal emits light in Spyro Reignited Trilogy, and we can achieve the same effect using a point light, so I’ve placed a point light inside of the portal and increased the range and I’ve set the color of the light to the same as the outline.

Next I added two particle systems to our portal, one is at the bottom of the portal and creates the more dense smoke particles, while the second particle system creates more sparse cloud particles that are higher (you can copy the exact settings from the project repo).

And this is it for this portal effect! I will try to export this into a shader graph at some point because I know it’s getting more popular now, but there are some issues with shader graphs where some of the features aren’t available yet so this might take some time.

With that said, I want to thank you for reading this short series of posts and I hope you enjoyed them! If you have any questions, feel free to reach out to me. Now I’m off to create more shaders!

GitHub Repo: https://github.com/danielpokladek/personal-shaders-repo

Repo at the state of this blog post: https://github.com/danielpokladek/personal-shaders-repo/tree/af1977f2ed297983a8462749ae1d06c50698729a


Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Back to top