First Person Mesh Rendering in Unity 5.4

If you've worked with Unity long enough, you'll know quite well that, almost since the dawn of Unity itself, a problem has plagued Unity users to no end - how to properly render meshes in first person view.

Up till now the solution was to render with a separate camera. This worked great in old versions of Unity, where light and shadow were really barely a concern. Then dynamic shadowing was introduced, and this method broke down - weapons in first person didn't receive shadows from scene geometry. It got a bit better with Unity 3.5, since you could use the new Light Probe feature to at least make your weapon vaguely look like it was receiving shadow from the scene. Then along came Unity 5 with Enlighten, and the light probe method is now broken since light probes don't bake shadow information for realtime lights, and since dual lightmaps are gone realtime lights are necessary for dynamic shadows.

So, back to the drawing board. How do we render a first person mesh in Unity 5? First, let's evaluate the options before I get to the solution I've come up with, so bear with me ;)

Keep in mind I'm assuming this is in the context of a game targeted at decent gaming PC rigs (not mobile). I'm therefore assuming deferred rendering is used.

Overlay Camera

I've already described why this one doesn't work, but we'll cover it for completeness. So, you put your first person renderers on their own layer, make sure the main camera doesn't render that layer, and have a separate weapon camera set to clear Depth Only which renders just that layer. And it doesn't work. Even though the second camera is set to Depth Only, it still appears to be clearing a solid color (grey in my case) and overwriting the first camera. No big deal, we'll just set it to Forward and - well, hold on, it still doesn't work? Right, Unity appears to have broken something here in Unity 5. This would have worked in Unity 4, though. So right there, this time-honored method is totally off the table now. What else do we have?

Really Tiny Gun

little gun Ain't that a cute little gun?

Turns out this is actually sort of the method Unreal Tournament 4 is using. You just scale the gun down really, really small so that it doesn't clip the environment. Oh, OK, so if that's what UT4 is doing then our problem is solved, right? Sadly, no. I would have liked the solution to really be that simple, but this introduces problems of its own.

For one, you don't get a custom FOV. Sounds like no big deal but actually you usually want to render your gun with a lower FOV than your main camera. Especially for a shooter, where players might be cranking their field of view all the way up to 90, which can introduce major distortion for your weapon models.

But then, there's another even bigger problem - that of floating point precision. Just 500 meters away from origin the gun had a vicious wobble that reminded me of a PS1 game. That alone makes this technique completely unusable for me, as 500m should be well within the limits of floating point precision. I'm not sure how Unreal is handling it, potentially via smarter concatenation of matrices. In any case, looks like this isn't going to work.

Is there any other way of rendering the gun? Turns out the answer is yes.

Custom Shader & Custom Projection Matrix

I can already feel your interest waning. "Wait, hold on, custom shader? You mean those third party shaders I'm using aren't going to work anymore? And what about surface shaders?"

OK, wait, bear with me! I wanted to make this as easy as possible on myself, so actually it's practically a one-line fix for a shader!

So, first, projection. The idea behind this is that I use a custom projection matrix which is borrowed from a separate disabled camera via camera.projectionMatrix, set up as a global shader variable. Additionally, I use that matrix in a custom version of UnityCG.cginc, which I copied from the unity editor CGInclude folder. Basically, there's a UnityObjectToClipPos function in there. It's responsible for applying the MVP matrix (model view projection) to the vertices, which projects them onto the screen. Surface shaders internally use this function, which is a good thing for us.

OK, first thing's first - I copied UnityCG.cginc and dumped it into my project directory. Not in the Assets folder, mind you. Just outside it, in the root of your project (this is important, because shader includes are relative to the project directory, not the Assets directory). Now, whenever a shader is compiled, it will compile in this new custom UnityCG.cginc file, rather than Unity's built in one. So far so good.

Next, I modified the UnityObjectToClipPos function. Remember, this code will be copied into a shader when it's compiled, so we can actually check for defined symbols. So I made two versions of the function - if a shader defines the FIRST_PERSON symbol, it compiles a version of that function which constructs a custom MVP matrix from it (and also multiplies z position by 0.5, which will offset our weapon's depth value and keep it from intersecting scene geometry):

float4x4 _CustomProjMatrix;

#if defined(FIRST_PERSON)
    // Tranforms position from object to homogenous space
    inline float4 UnityObjectToClipPos( in float3 pos )
    {
        float4x4 mvp = mul( mul( _CustomProjMatrix, UNITY_MATRIX_V ), unity_ObjectToWorld );
        float4 ret = mul( mvp, float4(pos, 1.0) );

        // hack to fix vertically flipped vertices post-projection
        ret.y *= -1;

        // hack to offset depth value to avoid scene geometry intersection
        // todo: check if it works in OpenGL?
        ret.z *= 0.5;

        return ret;
    }
#else
    // Tranforms position from object to homogenous space
    inline float4 UnityObjectToClipPos( in float3 pos )
    {
    #if defined(UNITY_SINGLE_PASS_STEREO) || defined(UNITY_USE_CONCATENATED_MATRICES)
        // More efficient than computing M*VP matrix product
        return mul(UNITY_MATRIX_VP, mul(unity_ObjectToWorld, float4(pos, 1.0)));
    #else
        return mul(UNITY_MATRIX_MVP, float4(pos, 1.0));
    #endif
    }
#endif

Then, I'll modify an example surface shader to add support for the new function. Turns out this is super easy! All you need is this:

#pragma shader_feature FIRST_PERSON

Super easy.

Next, we need a script which actually prepares this custom matrix. And here it is:

using UnityEngine;
using System.Collections;

public class SetWeaponProjectionMatrix : MonoBehaviour
{
    public Camera SourceCam;

    void LateUpdate()
    {
        Shader.SetGlobalMatrix("_CustomProjMatrix", SourceCam.projectionMatrix);
    }
}

And, finally, a script which enables the FIRST_PERSON feature on a renderer's material:

using UnityEngine;
using System.Collections;

public class EnableFirstPersonShaderVariant : MonoBehaviour
{
    void Awake()
    {
        GetComponent<Renderer>().material.EnableKeyword("FIRST_PERSON");
    }
}

And that's it. Surprisingly, it was not that hard at all. If you want to see the results, here's a video of this technique in action:

Further Notes: 5.4 Beta 16 & Motion Vectors

Now, one final note - this doesn't quite work for B16's new Motion Vector Buffer feature without a few extra steps. Unfortunately, here's where things start to get a bit more manual. For one, you need to change how you set up the projection matrices:

using UnityEngine;
using System.Collections;

public class SetWeaponProjectionMatrix : MonoBehaviour
{
    public Camera SourceCam;

    private Matrix4x4 prevProjMatrix;
    private Matrix4x4 lastView;

    private Camera cam;

    void Awake()
    {
        prevProjMatrix = SourceCam.projectionMatrix;
        lastView = SourceCam.transform.worldToLocalMatrix;
    }

    void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        prevProjMatrix = SourceCam.projectionMatrix;
        lastView = SourceCam.worldToCameraMatrix;

        Shader.SetGlobalMatrix("_PrevCustomVP", prevProjMatrix * lastView);
        Shader.SetGlobalMatrix("_CustomProjMatrix", SourceCam.projectionMatrix);

        Graphics.Blit(src, dest);
    }
}

Why OnRenderImage and not, say, Camera.onPreRender? I tried the latter, and it didn't work. I still don't really know why. Meanwhile, OnRenderImage does work even though it introduces that extra draw call from the blit (which kinda bugs me, but until I have a better solution it'll have to do).

Next, you need to modify your surface shader to add a custom motion vector pass. Here's what mine looks like:

Pass
    {
        CGINCLUDE

#include "UnityCG.cginc"

        struct MotionVertexInput
        {
            float4 vertex : POSITION;
            float3 oldPos : NORMAL;
        };

        struct v2f_motion_vectors
        {
            float4 transferPos : TEXCOORD0;
            float4 transferPosOld : TEXCOORD1;
            float4 pos : SV_POSITION;
        };

        float4x4 _PreviousVP;
        float4x4 _PreviousM;
        bool _HasLastPositionData;
        float _MotionVectorDepthBias;

        float4x4 _PrevCustomVP;

        v2f_motion_vectors vert_motion_vectors(MotionVertexInput v)
        {
            v2f_motion_vectors o;

            o.pos = UnityObjectToClipPos(v.vertex);

            // this works around an issue with dynamic batching
            // potentially remove in 5.4 when we use instancing
#if defined(UNITY_REVERSED_Z)
            o.pos.z -= _MotionVectorDepthBias * o.pos.w;
#else
            o.pos.z += _MotionVectorDepthBias * o.pos.w;
#endif

            o.transferPos = o.pos;

// Here, we're manually applying the custom projection matrix
// note that we're not actually using the last frame's camera view*proj matrix,
// for first person weapons it's unnecessary (you don't want blur from camera motion on them anyway)
#if defined(FIRST_PERSON)
            o.transferPosOld = mul(_PrevCustomVP, mul(_PreviousM, _HasLastPositionData ? float4(v.oldPos, 1) : v.vertex));
            o.transferPosOld.y *= -1;
#else
            o.transferPosOld = mul(_PreviousVP, mul(_PreviousM, _HasLastPositionData ? float4(v.oldPos, 1) : v.vertex));
#endif

            return o;
        }

        float4 frag_motion_vectors(v2f_motion_vectors i) : SV_Target
        {
            float3 hPos = (i.transferPos.xyz / i.transferPos.w);
            float3 hPosOld = (i.transferPosOld.xyz / i.transferPosOld.w);

            // V is the viewport position at this pixel in the range 0 to 1.
            float2 vPos = (hPos.xy + 1.0f) / 2.0f;
            float2 vPosOld = (hPosOld.xy + 1.0f) / 2.0f;

#if UNITY_UV_STARTS_AT_TOP
            vPos.y = 1.0 - vPos.y;
            vPosOld.y = 1.0 - vPosOld.y;
#endif
            half2 uvDiff = vPos - vPosOld;
            return half4(uvDiff, 0, 1);
        }
        ENDCG

        Tags {
            "LightMode" = "MotionVectors"
        }

    Name "MOTIONVECTORS"

        ZTest LEqual
        Cull Off
        ZWrite Off

        CGPROGRAM
        #pragma shader_feature FIRST_PERSON
        #pragma vertex vert_motion_vectors
        #pragma fragment frag_motion_vectors
        ENDCG
    }

This just gets pasted in right after the ENDCG of the surface shader. So, unfortunately it does make supporting custom shaders more of a pain, but it's still not that bad. And the results seem pretty good!

Posted in Coding & Dev on May 01, 2016


blog comments powered by Disqus
Subscribe for updates
RSS
Categories