Yipikiyay Blog

Yipikiyay Ltd

Here's looking at you kid

A research & development post from Matt, Yipikiyay's Technical Director

I had some time the other day to start building out a game mechanic I've wanted to put together. The idea is to trigger certain events based on the number of times you've been and/or looked into a room. It turned out to be really fun to write out so I wanted to share the steps taken to get it working here.

First things first; setting up a basic scene with a few walls, a floor, some light and a first person controller. (I've started using the Easy Character Movement plugin as a good starting point for character controllers.)

The scene I've made is a true work of art, it's absolutely beautiful.

Base scene

This challenge can be split up into two tasks, player presence detection & line of sight detection. Presence detection is easier so I went for that first, treating it as the warm up.

Player presence detection

This can be simply done using a trigger collider in the shape of the room. We know the character controller is going to need a collider on it so there will always be something to find collisions with.

Player presence collider

Instead of making the script that does this specific to rooms, we may want to know about the player (or another object) being in other zones at some point too, so let's keep this script nice and generic and write one which just notifies when a configurable collider has entered/exited the trigger using Unity Events.

public class TriggerStateChangedEvent : UnityEvent<bool> { }
public TriggerStateChangedEvent TriggerStateChanged;
public string tagFilter;

private void OnTriggerEnter(Collider collider)
{
    if (string.IsNullOrEmpty(tagFilter) || collider.tag == tagFilter)
    {
        TriggerStateChanged?.Invoke(true);
    }
}

private void OnTriggerExit(Collider collider)
{
    if(string.IsNullOrEmpty(tagFilter) || collider.tag == tagFilter)
    {
        TriggerStateChanged?.Invoke(false);
    }
}


Here you can see that we let a blank tagFilter variable act as a wildcard triggering the events for any collider, or you can set this value to filter which colliders this script looks for. We might want to change this to allow for multiple filters or to use layers going forwards, but let's try and leave it as simple as possible until we find a need to change it.

Next I created a room script which will be the place we store the current state of each room as we detect changes. For now this just adds a listener to the unity event on the detector script and toggles a bool so we can see it working in the editor. Simples

HasPlayerInside detection

A neat little trick here is that you can add multiple colliders to one object. So we can fill an odd shaped room with multiple box colliders. However this introduces a small issue. Notice in the gif below how the HasPlayerInside value is now changing incorrectly.

HasPlayerInside detection multiple colliders

This is because now we have multiple colliders, our detection script is correctly running the OnTriggerExit method when we exit the first one but it doesn't account for the fact we have actually entered the second collider, so it notifies that as an exit.

That's not what we want, if we have multiple colliders attached we want to treat them as one large combined collider. To solve this we can update the script to keep track of the number of enters and exits we hear, then only invoking the unity event when this count reaches 0 or it no longer equals.

public class TriggerStateChangedEvent : UnityEvent<bool> { }
public TriggerStateChangedEvent TriggerStateChanged;
public string tagFilter;
private int triggersInside = 0;

private void OnTriggerEnter(Collider collider)
{

    if (string.IsNullOrEmpty(tagFilter) || collider.tag == tagFilter)
    {
        triggersInside++;
        if (triggersInside == 1)
            TriggerStateChanged?.Invoke(true);
    }
}

private void OnTriggerExit(Collider collider)
{
    if(string.IsNullOrEmpty(tagFilter) || collider.tag == tagFilter)
    {
        triggersInside--;
        if (triggersInside == 0)
            TriggerStateChanged?.Invoke(false);
    }
}


HasPlayerInside detection 100% working

Nice. Warm up complete, so let's move onto the next problem.

Warm up complete

Line of sight detection

Unity Monobehaviours have special methods that get called by the engine at certain times. Two of which are:

  1. OnBecameVisible
  2. OnBecameInvisible

These sound like a nice quick starting point to test out, however there are a few problems:

  1. The items with this script would need to be things that are rendered. We couldn't use invisible scene objects to do the detection work.
  2. An item remains visible if it's in view of Unity's scene camera. This is super annoying, if we continued down this path it would mean a potentially large impact to workflow as I know I pretty much always have the scene view open.
  3. These methods are triggered as the object goes in and out of camera frustum due to frustum culling (an in built system which prevents rendering anything outside of the cameras frustum). However it doesn't take into account scene occlusion (other objects in the space getting in the way of the camera's line of sight). Now I suspect it would do if we were to bake occlusion culling data, but then this would only work with spaces marked as static and I was thinking we'd be able to have quite a dynamically changing space. I didn't test this last step as I'd made the decision by now another solution was required.

So Unity isn't giving us any shortcuts, we're going to have to write it out ourselves.

Shortcut

The first question we should think about is what is line of sight into a room? It's an unobstructed straight line from our eyes (the game camera) to any of the room's entrances. We can write a script to check for that that's generic just like in part 1, so we could reuse it for other situations going forwards. It would also be neat to make it work with collider bounds instead of specific renderers, so it works completely outside of the rendering system.

I created a NotifyOnVisible script and attached it, as well as a box collider, to a new GameObject in the scene. The box collider was setup up to match the shape of the rooms entranceway, then it's time to get to work in the script.

To check for this line of sight we're going to have to start using linecasts to the target. However linecasts are (relatively) expensive from a performance perspective so let's try to avoid doing them if we can. If our collider isn't even in the camera's frustum we don't need to linecast at all, so let's first check for that.

private void Update()
{
    if (_camera != null)
    {
        for (int i = 0; i < colliders.Length; i++)
        {
            bounds[i] = colliders[i].bounds;
        }
        Plane[] frustumPlanes = GeometryUtility.CalculateFrustumPlanes(_camera);
        List<Bounds> boundsInFrustum = new List<Bounds>();
        for(int i = 0; i < bounds.Length; i++)
        {
            if(GeometryUtility.TestPlanesAABB(frustumPlanes, bounds[i]))
            {
                boundsInFrustum.Add(bounds[i]);
            }
        }
    }
}


Assuming colliders is a collection of the colliders we want to check against, like before in part 1 that can be multiple colliders on the same object. In this snippet of code we're getting the camera's frustum using CalculateFrustumPlanes method then checking if our colliders bounds are within it using the GeometryUtility.TestPlanesAABB method. If they are we pop the bounds into a new list. So by the time this has run we have a filtered list containing only the bounds within our frustum.

I hadn't used CalculateFrustumPlanes or TestPlanesAABB very frequently before, so ran a quick profile check to see how much of an impact they had to the execution time of the frame. TestPlanesAABB seemed fine but grabbing the camera's frustum seemed to have a bit of a hit every time it was called. As we could have many of these detection scripts throughout the scene running this same code, and given that each one would be calculating the same result in the same frame, we could get a bit of a saving if we cached that value every frame.

This could be done either by setting up a manager for all of these detection scripts, which gets the frustum and passes it into the detection script every frame. Or through a static method with a private cached result for that frame. I opted for the latter as it's more flexible than needing an object in the scene that holds reference to all detectors. I called my version of the class GeometryUtilityExtra and added the following:

using UnityEngine;

public class GeometryUtilityExtra
{
    private static int cachedFrame;
    private static Plane[] planes;
    public static Plane[] CalculateFrustumPlanes(Camera _camera)
    {
        if (planes == null || cachedFrame != Time.frameCount)
        {
            cachedFrame = Time.frameCount;
            planes = GeometryUtility.CalculateFrustumPlanes(_camera);
        }
        return planes; 

    }
}


As you can see, regardless of how many times this is called and no matter where from, the GeometryUtility.CalculateFrustumPlanes method will only be called at max once per frame now. Sweet, that helps keep this thing scalable in much larger scenes.

Back to the main task, we now know all of the bounds that are possibly visible, let's now linecast to that object to check if line of sight is obstructed.

bool _isVisible = false;
foreach (Bounds bound in boundsInFrustum)
{
    if(!Physics.Linecast(origin, bound.center, Physics.DefaultRaycastLayers, QueryTriggerInteraction.Ignore))
    {
        _isVisible = true;
        break;
    }
}


We can break from the loop if any of the colliders have no collisions on the linecast as we only need to be able to see to one of the rooms entrances to know we can see into the room.

Now by the end of the update method, we will know if the bounds are in our line of sight by checking the _isVisible value. I also started adding Debug.DrawLine for any linecasts made so we can see what's happening in the editor, changing it's colour depending on the outcome.

Single linecast

Single linecast

Not bad, but we're only currently only checking one line to the center of our collider which won't work if something partially covers the entrance but doesn't cover the edges. Let's add some additional checks to linecast to a number of points on our collider, lets go for the center point still as well as each corner.

bool _isVisible = false;
foreach (Bounds bound in boundsInFrustum)
{
    Vector3[] lineCastTargets = new Vector3[5]
    {
        bound.center,
        new Vector3(bound.center.x, bound.center.y - bound.extents.y, bound.center.z + bound.extents.z),
        new Vector3(bound.center.x, bound.center.y + bound.extents.y, bound.center.z + bound.extents.z),
        new Vector3(bound.center.x, bound.center.y - bound.extents.y, bound.center.z - bound.extents.z),
        new Vector3(bound.center.x, bound.center.y + bound.extents.y, bound.center.z - bound.extents.z)
    }

    for (int i = 0; i < lineCastTargets.Length; i++)
    {   
        if(!Physics.Linecast(origin, lineCastTargets[i], Physics.DefaultRaycastLayers, QueryTriggerInteraction.Ignore))
        {
            _isVisible = true;
            break;
        }
    }
    if(_isVisible) break;
}


Now is when I have to admit a bit of a brain fart while doing this. Due to how I had to collider rotated I started dealing with Y & Z and not X & Y. It makes no difference other than our brains being trained to expect X & Y so I rolled with it. In hindsight I should have just changed the rotation of the collider and not been so special.

Stupid me

Now we're checking the center and each corner of the collider, that should be ok right? Well maybe. It will depend on the geometry and sorts of angles the player gets to in your scene, for my scene however it's not enough.

Doorway corners detection

You can see from the scene view in that gif the depth of the doorway alone causes these issues in my setup. So let's move this up a level and allow for a configurable amount of points to be line cast within the collider bounds. That way we can control the level of fidelity this system should use per entrance.

It made sense to me the most likely line of sight was going to be straight to the middle of the entrance. So I thought if we're going to be testing loads of points, and want to break out of that as early as possible, let's start at the center and work outwards.

Planned detection shape

Based on having the center and extremities of the bounds, the maths for working this out is some good old fun GCSE maths, linear equations. Remember, we only need 1 of those lines to not be obstructed to know we have line of sight, so once we find 1 we can just break and stop processing that room.

Maths

The formula to calculate the Y coordinate for any given X (or in our case Z) coordinate on our line of one corner to another is y = mx + b (but swap X for Z). We need to work out m (the gradient) which we can do by dividing the change in y values by the change in x values. Assuming we're looking at the line running through the center point to the top right corner, in our code that equates to:

float m = bound.extents.y / bound.extents.z


b can be worked if we pop the values of a known point on our line into the formula now we know m.

Let's do that with the center point:

// y = mx + b
// bound.center.y = m * bound.center.z + b;
// rearranges to:
float b = bound.center.y - (m * bound.center.z)


Now we have m & b (and we can run the same process to get them for the inverse line on the other corners) we can work out any point along those lines. So we can now create a loop which uses a step value indicating how much distance we want between each point in the Z axis, then run the same linecast code written above. I decided to also add in points along the horizontal axis too.

Linecast with step

That's looking much more accurate - and as it's configurable based on the step value, it's much more flexible too.

One final optimisation step I added was to make sure the points within the collider bounds we've calculated were all within the camera's frustum before marking them as needing a linecast. Our original frustum check only picked up if the whole bounds were outside the frustum, but we could save a lot of linecasts if only a very small section of the bounds are in view.

For this we could use the handy TestPlanesAABB method again. The only downside to this is it only takes a bounds and we only have a Vector3 point. I wrote a method for this check which instantiates a new bounds without a size at the requested point.

private bool CheckWithinFrustum(Plane[] cameraFrustum, Vector3 vector)
{
    return GeometryUtility.TestPlanesAABB(cameraFrustum, new Bounds(vector, Vector3.zero));
}


This worked and I was feeling lazy at the time so felt ok with it, however it does add extra allocation that's not actually necessary. This could be improved by using the raw maths to work this out instead of the handy helper function.

This was actually my second thought, my first involved using a method on the camera which converts a Vector3 from world space to 2d viewport space.

Vector3 pointInViewport = _camera.WorldToViewportPoint(vector);
return pointInViewport.x >= 0 && pointInViewport.x <= 1 && pointInViewport.y >= 0 && pointInViewport.y <= 1;


This however is actually quite a slow method, and it turns out using the TestPlanesAABB method is much faster, so ended up being the approach I used.

Working result

And there we go. We now have a system which can accurately tell us if we have a clear line of sight to a shape in a dynamic scene. It's careful not to do any more processing than needed and has a controllable fidelity value.

I also added a way to toggle if it should run it's processing or not, which when combined with that fact we know which room a user is in means we can only let this code execute when you're in an adjacent room (or one we know line of sight might be possible).

I hooked this into the room script and made the room toggle it's contents everytime the user looked away to prove this all worked.

End result

End result

End result

As a user I never saw the room change and the scene setup is all left 100% dynamic, which is exactly what we were after.

Yes!

Now to start building the game to go with this mechanic...

Until next time. Yipikiyay!

by Matt Murton.